diff --git a/repi.session.sql b/repi.session.sql new file mode 100644 index 0000000..e69de29 diff --git a/repi/api/chat.py b/repi/api/chat.py index 7c19874..2ee4381 100644 --- a/repi/api/chat.py +++ b/repi/api/chat.py @@ -24,12 +24,10 @@ import logging from collections import Counter from datetime import datetime, timedelta, timezone -from typing import List, Literal, Optional from uuid import UUID, uuid4 from fastapi import APIRouter from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field from sqlalchemy import select, text as sa_text from repi.core.container import get_container @@ -44,6 +42,7 @@ from repi.llm.provider import Message from repi.models.filters import RetrievalFilters from repi.models.schema import ChatMessage, Conversation +from repi.api.schemas import ChatFilters, ChatRequest, ChatTurn from repi.retrieval.cluster_view import cluster_chunks from repi.retrieval.timeline_view import build_timeline @@ -52,43 +51,6 @@ router = APIRouter() -# ── Request / response models ───────────────────────────────────────────────── - - -class ChatTurn(BaseModel): - role: Literal["user", "assistant"] - content: str - - -class ChatFilters(BaseModel): - service: Optional[str] = None - time_from: Optional[datetime] = None - time_to: Optional[datetime] = None - entity: Optional[str] = None - - -class ChatRequest(BaseModel): - query: str - history: List[ChatTurn] = [] - filters: Optional[ChatFilters] = None - conversation_id: Optional[UUID] = None - # Followup-bias hint: chunk_ids the previous assistant turn cited. When - # the current query is missing EITHER an explicit service or an explicit - # time window, the chat path fills in just the missing dimension from - # the previous turn's chunks — service via dominant-source check, time - # via a `Settings.FOLLOWUP_BIAS_WINDOW_MINUTES` envelope around their - # timestamps. Soft — never overrides an explicit filter, silently - # ignored if the IDs no longer resolve. - # - # Capped at 50 to bound the indexed-PK fetch and reject malformed - # payloads early. The legitimate caller only ever sends the last - # assistant turn's citations (≤10 in practice). - previous_chunk_ids: List[str] = Field(default_factory=list, max_length=50) - # UX P1: scopes retrieval + known-services resolution to one project. If - # omitted but the conversation has a project, that project applies. - project_id: Optional[UUID] = None - - # ── Module-level constants ──────────────────────────────────────────────────── # Caller-visible window on cited-chunk `text` in the SSE done payload. Locked diff --git a/repi/api/conversations.py b/repi/api/conversations.py index 13548d5..0c78de9 100644 --- a/repi/api/conversations.py +++ b/repi/api/conversations.py @@ -7,51 +7,21 @@ from __future__ import annotations import logging -from typing import List, Literal, Optional +from typing import List from uuid import UUID from fastapi import APIRouter, HTTPException -from pydantic import BaseModel from sqlmodel import select from repi.core.container import get_container from repi.models.schema import ChatMessage, Conversation, Investigation, Project +from repi.api.schemas import ConversationDetail, ConversationSummary, TranscriptTurn logger = logging.getLogger("repi.api.conversations") router = APIRouter() -class ConversationSummary(BaseModel): - id: str - title: Optional[str] - project_id: Optional[str] = None - project_name: Optional[str] = None - created_at: str - updated_at: str - - -class TranscriptTurn(BaseModel): - mode: Literal["chat", "investigate"] - id: str - role: Optional[str] = None # "user" | "assistant" for chat turns - content: str - chunk_ids: List[str] = [] - confidence: Optional[str] = None - status: Optional[str] = None # investigation status (chat turns leave this null) - created_at: str - - -class ConversationDetail(BaseModel): - id: str - title: Optional[str] - project_id: Optional[str] = None - project_name: Optional[str] = None - created_at: str - updated_at: str - turns: List[TranscriptTurn] - - @router.get("/conversations", response_model=List[ConversationSummary]) async def list_conversations(limit: int = 50): container = get_container() diff --git a/repi/api/ingest.py b/repi/api/ingest.py index c765fd4..e68732d 100644 --- a/repi/api/ingest.py +++ b/repi/api/ingest.py @@ -1,21 +1,12 @@ import logging from fastapi import APIRouter, UploadFile, File, Form, Depends -from pydantic import BaseModel from repi.core.container import get_container +from repi.api.schemas import IngestResponse logger = logging.getLogger("repi.api.ingest") router = APIRouter() -class IngestResponse(BaseModel): - service: str - project: str - chunk_count: int - lines_total: int - lines_with_timestamp: int - level_counts: dict[str, int] - message: str - @router.post("/ingest", response_model=IngestResponse) async def ingest( service: str = Form(...), diff --git a/repi/api/investigate.py b/repi/api/investigate.py index 84dd30c..989587f 100644 --- a/repi/api/investigate.py +++ b/repi/api/investigate.py @@ -4,59 +4,23 @@ from typing import Optional, List from fastapi import APIRouter, HTTPException, Depends from fastapi.responses import StreamingResponse -from pydantic import BaseModel from uuid import UUID from datetime import datetime from repi.core.container import get_container from repi.investigation.react_loop import InvestigationStep +from repi.api.schemas import ( + ClarifyRequest, + InvestigateRequest, + InvestigationResponse, + InvestigationStepModel, + SimpleInvestigationResponse, +) logger = logging.getLogger("repi.api.investigate") router = APIRouter() -class InvestigateRequest(BaseModel): - query: str - resume: bool = True - # Optional thread back to a chat surface (A1/A2). If omitted, a new - # conversation row is created and its id returned so the UI can attach - # subsequent /chat turns to the same thread. - conversation_id: Optional[UUID] = None - # UX P1: scopes retrieval + every ReAct tool to one project. If omitted - # but the conversation has a project, the conversation's project applies. - project_id: Optional[UUID] = None - -class InvestigationStepModel(BaseModel): - step_number: int - thought: str - # Legacy preview fields — kept for back-compat with anything that may still - # read them. The list endpoint returns empty steps anyway. - tool_name: Optional[str] = None - tool_args: Optional[dict] = None - observation_preview: Optional[str] = None - # Rich shape the UI uses to render a step identically to the SSE stream. - action: Optional[dict] = None - observation: Optional[dict] = None - kind: Optional[str] = None - -class InvestigationResponse(BaseModel): - id: str - query: str - status: str - answer: Optional[str] = None - created_at: datetime - steps: List[InvestigationStepModel] - pending_question: Optional[str] = None - stats: Optional[dict] = None - -class SimpleInvestigationResponse(BaseModel): - id: str - status: str - conversation_id: Optional[str] = None - -class ClarifyRequest(BaseModel): - reply: str - @router.get("/investigations", response_model=List[InvestigationResponse]) async def list_investigations(limit: int = 20): """List recent investigations.""" diff --git a/repi/api/projects.py b/repi/api/projects.py index c179be8..d27cb16 100644 --- a/repi/api/projects.py +++ b/repi/api/projects.py @@ -13,13 +13,13 @@ from uuid import UUID from fastapi import APIRouter, HTTPException -from pydantic import BaseModel from sqlalchemy import text as sa_text from sqlmodel import select from repi.core.container import get_container from repi.core.dates import DateHandler from repi.models.schema import Project +from repi.api.schemas import ProjectCreate, ProjectRead, ProjectService, ProjectUpdate from repi.retrieval.event_feed import derive_events, fetch_window_aggregates, parse_window logger = logging.getLogger("repi.api.projects") @@ -76,33 +76,6 @@ async def _get_or_create_by_name(session, name: str) -> Project: return row -# ── Models ─────────────────────────────────────────────────────────────────── - -class ProjectCreate(BaseModel): - name: str - settings: dict[str, Any] = {} - - -class ProjectUpdate(BaseModel): - name: Optional[str] = None - settings: Optional[dict[str, Any]] = None - - -class ProjectRead(BaseModel): - id: str - name: str - settings: dict[str, Any] - service_count: int = 0 - created_at: datetime - updated_at: datetime - - -class ProjectService(BaseModel): - name: str - chunk_count: int - last_seen: Optional[datetime] = None - - # ── Endpoints ──────────────────────────────────────────────────────────────── @router.get("/projects", response_model=List[ProjectRead]) @@ -275,9 +248,6 @@ async def project_overview( for r in svc_rows ] - # Suggested actions — derived, never LLM-generated. Top clusters become - # Deep-Research entry points with the service + time range pre-filled so - # the investigation starts grounded instead of from a bare phrase. suggested: list[dict] = [] for c in clusters[:3]: svc_part = f" on {c['services'][0]}" if c["services"] else "" @@ -289,16 +259,6 @@ async def project_overview( f"between {c['first_ts']} and {c['last_ts']}" ), }) - suggested.append({ - "kind": "chat", - "label": f"Summarize the last {window_str}", - "query": f"summarize what happened in the last {window_str}", - }) - suggested.append({ - "kind": "chat", - "label": "Show affected services", - "query": "which services are having problems?", - }) return { "project_id": str(project_id), diff --git a/repi/api/schemas.py b/repi/api/schemas.py new file mode 100644 index 0000000..7d81241 --- /dev/null +++ b/repi/api/schemas.py @@ -0,0 +1,207 @@ +"""Shared API request/response models. + +Pydantic models for the HTTP surface, extracted out of the individual route +modules so they live in one tagged, importable place (mirrors the +``repi/models/`` convention for domain/db models). The route modules import +these back in; FastAPI behaviour, validation and the generated OpenAPI schema +are unchanged by the move. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, List, Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +# ── Ingest ──────────────────────────────────────────────────────────────────── + +class IngestResponse(BaseModel): + service: str + project: str + chunk_count: int + lines_total: int + lines_with_timestamp: int + level_counts: dict[str, int] + message: str + + +# ── Watchers ────────────────────────────────────────────────────────────────── + +class WatcherConfigCreate(BaseModel): + service_name: str + watch_path: str + env: str = "production" + enabled: bool = True + project_id: UUID | None = None + + +class WatcherConfigRead(BaseModel): + id: UUID + service_name: str + watch_path: str + env: str + enabled: bool + project_id: UUID | None = None + created_at: datetime + updated_at: datetime + + +class WatcherConfigUpdate(BaseModel): + service_name: str = None + watch_path: str = None + env: str = None + enabled: bool = None + project_id: UUID = None + + +class WatcherStatus(BaseModel): + file_path: str + offset: int + last_seen_at: datetime + updated_at: datetime + + +# ── Projects ────────────────────────────────────────────────────────────────── + +class ProjectCreate(BaseModel): + name: str + settings: dict[str, Any] = {} + + +class ProjectUpdate(BaseModel): + name: Optional[str] = None + settings: Optional[dict[str, Any]] = None + + +class ProjectRead(BaseModel): + id: str + name: str + settings: dict[str, Any] + service_count: int = 0 + created_at: datetime + updated_at: datetime + + +class ProjectService(BaseModel): + name: str + chunk_count: int + last_seen: Optional[datetime] = None + + +# ── Conversations ───────────────────────────────────────────────────────────── + +class ConversationSummary(BaseModel): + id: str + title: Optional[str] + project_id: Optional[str] = None + project_name: Optional[str] = None + created_at: str + updated_at: str + + +class TranscriptTurn(BaseModel): + mode: Literal["chat", "investigate"] + id: str + role: Optional[str] = None # "user" | "assistant" for chat turns + content: str + chunk_ids: List[str] = [] + confidence: Optional[str] = None + status: Optional[str] = None # investigation status (chat turns leave this null) + created_at: str + + +class ConversationDetail(BaseModel): + id: str + title: Optional[str] + project_id: Optional[str] = None + project_name: Optional[str] = None + created_at: str + updated_at: str + turns: List[TranscriptTurn] + + +# ── Investigations ──────────────────────────────────────────────────────────── + +class InvestigateRequest(BaseModel): + query: str + resume: bool = True + # Optional thread back to a chat surface (A1/A2). If omitted, a new + # conversation row is created and its id returned so the UI can attach + # subsequent /chat turns to the same thread. + conversation_id: Optional[UUID] = None + # UX P1: scopes retrieval + every ReAct tool to one project. If omitted + # but the conversation has a project, the conversation's project applies. + project_id: Optional[UUID] = None + + +class InvestigationStepModel(BaseModel): + step_number: int + thought: str + # Legacy preview fields — kept for back-compat with anything that may still + # read them. The list endpoint returns empty steps anyway. + tool_name: Optional[str] = None + tool_args: Optional[dict] = None + observation_preview: Optional[str] = None + # Rich shape the UI uses to render a step identically to the SSE stream. + action: Optional[dict] = None + observation: Optional[dict] = None + kind: Optional[str] = None + + +class InvestigationResponse(BaseModel): + id: str + query: str + status: str + answer: Optional[str] = None + created_at: datetime + steps: List[InvestigationStepModel] + pending_question: Optional[str] = None + stats: Optional[dict] = None + + +class SimpleInvestigationResponse(BaseModel): + id: str + status: str + conversation_id: Optional[str] = None + + +class ClarifyRequest(BaseModel): + reply: str + + +# ── Chat ────────────────────────────────────────────────────────────────────── + +class ChatTurn(BaseModel): + role: Literal["user", "assistant"] + content: str + + +class ChatFilters(BaseModel): + service: Optional[str] = None + time_from: Optional[datetime] = None + time_to: Optional[datetime] = None + entity: Optional[str] = None + + +class ChatRequest(BaseModel): + query: str + history: List[ChatTurn] = [] + filters: Optional[ChatFilters] = None + conversation_id: Optional[UUID] = None + # Followup-bias hint: chunk_ids the previous assistant turn cited. When + # the current query is missing EITHER an explicit service or an explicit + # time window, the chat path fills in just the missing dimension from + # the previous turn's chunks — service via dominant-source check, time + # via a `Settings.FOLLOWUP_BIAS_WINDOW_MINUTES` envelope around their + # timestamps. Soft — never overrides an explicit filter, silently + # ignored if the IDs no longer resolve. + # + # Capped at 50 to bound the indexed-PK fetch and reject malformed + # payloads early. The legitimate caller only ever sends the last + # assistant turn's citations (≤10 in practice). + previous_chunk_ids: List[str] = Field(default_factory=list, max_length=50) + # UX P1: scopes retrieval + known-services resolution to one project. If + # omitted but the conversation has a project, that project applies. + project_id: Optional[UUID] = None diff --git a/repi/api/watchers.py b/repi/api/watchers.py index 76d38e8..51f0255 100644 --- a/repi/api/watchers.py +++ b/repi/api/watchers.py @@ -3,46 +3,21 @@ from uuid import UUID, uuid4 from datetime import datetime from fastapi import APIRouter, HTTPException, Depends -from pydantic import BaseModel from sqlmodel import select from repi.core.container import get_container from repi.models.schema import WatcherConfig, WatcherOffset +from repi.api.schemas import ( + WatcherConfigCreate, + WatcherConfigRead, + WatcherConfigUpdate, + WatcherStatus, +) logger = logging.getLogger("repi.api.watchers") router = APIRouter() -class WatcherConfigCreate(BaseModel): - service_name: str - watch_path: str - env: str = "production" - enabled: bool = True - project_id: UUID | None = None - -class WatcherConfigRead(BaseModel): - id: UUID - service_name: str - watch_path: str - env: str - enabled: bool - project_id: UUID | None = None - created_at: datetime - updated_at: datetime - -class WatcherConfigUpdate(BaseModel): - service_name: str = None - watch_path: str = None - env: str = None - enabled: bool = None - project_id: UUID = None - -class WatcherStatus(BaseModel): - file_path: str - offset: int - last_seen_at: datetime - updated_at: datetime - @router.post("/watchers", response_model=WatcherConfigRead) async def create_watcher(config: WatcherConfigCreate): container = get_container() diff --git a/web/app/globals.css b/web/app/globals.css index ee2b7a3..bccb224 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -7,9 +7,9 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-jakarta); - --font-mono: var(--font-mono); - --font-heading: var(--font-jakarta); + --font-sans: var(--font-geist); + --font-mono: var(--font-geist-mono); + --font-heading: var(--font-geist); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -127,4 +127,104 @@ html { @apply font-sans; } +} + +/* Markdown rendering for chat prose / compiled answers / step thoughts. + Tailwind's preflight strips element margins, so ReactMarkdown output needs + its own typographic spacing. Inline `code` gets a Notion-like warm orange + hue; fenced code blocks keep the neutral mono treatment. */ +@layer components { + .md-content { + line-height: 1.6; + word-break: break-word; + } + .md-content > :first-child { + margin-top: 0; + } + .md-content > :last-child { + margin-bottom: 0; + } + .md-content p { + margin: 0.5em 0; + } + .md-content ul, + .md-content ol { + margin: 0.5em 0; + padding-left: 1.25em; + } + .md-content ul { + list-style: disc; + } + .md-content ol { + list-style: decimal; + } + .md-content li { + margin: 0.15em 0; + } + .md-content li > ul, + .md-content li > ol { + margin: 0.15em 0; + } + .md-content h1, + .md-content h2, + .md-content h3, + .md-content h4 { + font-weight: 600; + line-height: 1.3; + margin: 0.75em 0 0.35em; + } + .md-content h1 { + font-size: 1.25em; + } + .md-content h2 { + font-size: 1.15em; + } + .md-content h3 { + font-size: 1.05em; + } + .md-content a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; + } + .md-content strong { + font-weight: 600; + } + .md-content em { + font-style: italic; + } + .md-content blockquote { + border-left: 2px solid var(--border); + padding-left: 0.75em; + margin: 0.5em 0; + color: var(--muted-foreground); + } + .md-content pre { + margin: 0.5em 0; + padding: 0.75em; + border-radius: 0.5rem; + background: var(--muted); + overflow-x: auto; + font-family: var(--font-mono); + font-size: 0.85em; + } + .md-content pre code { + background: none; + color: inherit; + padding: 0; + } + /* Notion-like warm orange inline code (not fenced blocks). */ + .md-content :not(pre) > code { + font-family: var(--font-mono); + font-size: 0.85em; + color: oklch(0.62 0.17 45); + background: oklch(0.62 0.17 45 / 0.1); + padding: 0.12em 0.34em; + border-radius: 0.32rem; + white-space: break-spaces; + } + .dark .md-content :not(pre) > code { + color: oklch(0.78 0.15 55); + background: oklch(0.78 0.15 55 / 0.14); + } } \ No newline at end of file diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 9352fe2..c5a66fe 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,13 +1,21 @@ import type { Metadata } from "next"; -import { Plus_Jakarta_Sans } from "next/font/google"; +import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { Navbar } from "@/components/navbar"; -const jakarta = Plus_Jakarta_Sans({ +// Mellow, developer-native pairing: Geist for body/headings, Geist Mono for +// code/logs. Geist Mono finally backs the `--font-mono` slot that globals.css +// referenced but nothing previously loaded. +const geist = Geist({ subsets: ["latin"], - variable: "--font-jakarta", + variable: "--font-geist", +}); + +const geistMono = Geist_Mono({ + subsets: ["latin"], + variable: "--font-geist-mono", }); export const metadata: Metadata = { @@ -23,7 +31,7 @@ export default function RootLayout({ return (