Spaces:
Sleeping
Sleeping
| """Pydantic v2 schemas for all API request and response payloads.""" | |
| from datetime import UTC, datetime | |
| from enum import Enum, StrEnum | |
| from typing import Any | |
| from pydantic import BaseModel, Field, field_validator, model_validator | |
| # ββ Generation mode ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class GenerationMode(StrEnum): | |
| """The three AI operation modes exposed in the UI.""" | |
| generate = "generate" | |
| """Personalised content generation: RAG retrieval + style adaptation.""" | |
| proofread = "proofread" | |
| """Proofreading: grammar, clarity, and style-consistency review.""" | |
| enhance = "enhance" | |
| """Technical enhancement: expand depth using uploaded + system documents.""" | |
| # ββ Writing style ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class WritingStyleProfile(BaseModel): | |
| """Detected writing style extracted from the user's uploaded documents.""" | |
| tone: str = Field(default="formal", description="Overall tone: formal | semi-formal | technical | casual") | |
| formality_level: str = Field(default="professional", description="Professional | academic | conversational") | |
| avg_sentence_complexity: str = Field(default="moderate", description="Simple | moderate | complex") | |
| vocabulary_level: str = Field(default="technical", description="Basic | intermediate | technical | specialist") | |
| common_phrases: list[str] = Field(default_factory=list, description="Frequently used phrases or openers") | |
| structural_patterns: list[str] = Field(default_factory=list, description="Observed sentence/paragraph patterns") | |
| writing_style_summary: str = Field( | |
| default="Professional RICS survey style with factual, measured language.", | |
| description="Short human-readable summary of the detected style", | |
| ) | |
| example_paragraphs: list[str] = Field( | |
| default_factory=list, | |
| description="Representative paragraphs from this surveyor's reports used as few-shot style guides", | |
| ) | |
| # ββ Upload ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class UploadResponse(BaseModel): | |
| """Response returned after a successful file upload.""" | |
| document_id: str | |
| tenant_id: str | |
| filename: str | |
| status: str | |
| message: str | |
| class DocumentSurveyLevelUpdate(BaseModel): | |
| """Assign the RICS Home Survey **product** tier to an uploaded document (library filtering).""" | |
| survey_level: int = Field( | |
| ..., | |
| ge=1, | |
| le=3, | |
| description="1 = Condition Report, 2 = HomeBuyer / Level 2, 3 = Building Survey / Level 3", | |
| ) | |
| class DocumentSurveyLevelResponse(BaseModel): | |
| """Acknowledgement after updating ``survey_level`` on a document.""" | |
| document_id: str | |
| survey_level: int | |
| detail: str = "survey_level saved; future RAG uses this tier for library filtering." | |
| class SurveyLevelScoreCandidate(BaseModel): | |
| """One tier and its relative score from the classifier.""" | |
| survey_level: int = Field(ge=0, le=3) | |
| score: float | |
| class SurveyLevelClassificationItem(BaseModel): | |
| """Per-document tier suggestion with explicit rationale (no silent auto-apply). | |
| ``predicted_survey_level=0`` means the classifier could not determine the tier | |
| (file unreadable, too short, no corpus, no phrase signals). The UI should prompt | |
| the user to select the tier manually rather than auto-applying a guess. | |
| """ | |
| document_id: str | |
| filename: str | |
| predicted_survey_level: int = Field(ge=0, le=3) | |
| confidence: float = Field(ge=0.0, le=1.0) | |
| candidates: list[SurveyLevelScoreCandidate] | |
| rationale: str | |
| class SurveyLevelClassifyRequest(BaseModel): | |
| """Batch classify uploads using the local exemplar corpus (``knowledge_base_dirs``).""" | |
| document_ids: list[str] = Field(..., min_length=1, max_length=80) | |
| force_refresh_corpus: bool = Field( | |
| default=False, | |
| description="Rebuild cached corpus profiles from disk before scoring.", | |
| ) | |
| def _dedupe_ids(cls, v: list[str]) -> list[str]: | |
| seen: set[str] = set() | |
| out: list[str] = [] | |
| for x in v: | |
| s = str(x).strip() | |
| if not s or s in seen: | |
| continue | |
| seen.add(s) | |
| out.append(s) | |
| if not out: | |
| raise ValueError("document_ids must contain at least one unique id") | |
| return out | |
| class SurveyLevelClassifyResponse(BaseModel): | |
| """Classifier output for each requested document.""" | |
| items: list[SurveyLevelClassificationItem] | |
| corpus_labelled_files: int = Field( | |
| default=0, | |
| description="Exemplar KB files used to build lexical profiles (inferred tier per file).", | |
| ) | |
| class SurveyLevelApplyItem(BaseModel): | |
| """Confirm a single document's RICS product tier after user review.""" | |
| document_id: str | |
| survey_level: int = Field(ge=1, le=3) | |
| class SurveyLevelApplyRequest(BaseModel): | |
| """Persist confirmed tiers β same effect as repeated PATCH /documents/{id}/survey-level.""" | |
| items: list[SurveyLevelApplyItem] = Field(..., min_length=1, max_length=80) | |
| class SurveyLevelApplyResponse(BaseModel): | |
| """Rows updated; ingestion is not restarted (DB column drives retrieval filtering).""" | |
| updated: list[DocumentSurveyLevelResponse] | |
| class SurveyLevelCorpusRefreshRequest(BaseModel): | |
| """Optional body for corpus profile rebuild.""" | |
| force: bool = Field(default=True, description="When true, ignore TTL and rebuild from disk.") | |
| class SurveyLevelCorpusRefreshResponse(BaseModel): | |
| """Observability after rebuilding cached corpus statistics.""" | |
| files_used: int | |
| created_at_unix: int | |
| class TemplateCatalogSection(BaseModel): | |
| """One row in the UI section list for a given RICS product tier.""" | |
| code: str | |
| title: str | |
| group: str | |
| hint: str = Field(default="", description="Short prompt for inspection notes (derived from expected fields)") | |
| class TemplateCatalogResponse(BaseModel): | |
| """Full section manifest for configuring generation β depends on survey product level.""" | |
| survey_level: int = Field(ge=1, le=3) | |
| product_name: str | |
| docx_title: str | |
| sections: list[TemplateCatalogSection] | |
| group_labels: dict[str, str] | |
| class BatchUploadItem(BaseModel): | |
| """One row from a bulk upload: accepted into the DB or rejected with a reason.""" | |
| document_id: str | None = None | |
| filename: str | |
| status: str = Field(description="accepted | rejected") | |
| message: str = "" | |
| class BulkUploadResponse(BaseModel): | |
| """Response from POST /upload/batch β each file is stored as its own ``Document`` row.""" | |
| tenant_id: str | |
| accepted: int | |
| rejected: int | |
| items: list[BatchUploadItem] | |
| message: str = "" | |
| class DocumentBatchStatusRequest(BaseModel): | |
| """Body for POST /documents/batch-status.""" | |
| document_ids: list[str] = Field( | |
| ..., | |
| description="Document UUIDs to query (must belong to the authenticated tenant)", | |
| ) | |
| def _cap_id_count(cls, v: list[str]) -> list[str]: | |
| from app.config import settings | |
| cap = settings.max_batch_status_document_ids | |
| if len(v) > cap: | |
| raise ValueError(f"Too many document_ids (max {cap})") | |
| return v | |
| class DocumentStatusItem(BaseModel): | |
| document_id: str | |
| status: str | |
| filename: str = "" | |
| error: str | None = None | |
| class DocumentBatchStatusResponse(BaseModel): | |
| """Aggregated ingestion status for many documents in one round-trip.""" | |
| items: list[DocumentStatusItem] | |
| pending: int | |
| processing: int | |
| complete: int | |
| failed: int | |
| # ββ Parsed document structure βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class ParsedBlock(BaseModel): | |
| """A single structural block extracted from a document. | |
| Example:: | |
| ParsedBlock(block_type="heading", text="1. Location", level=1, page=1) | |
| """ | |
| block_type: str = Field( | |
| description="One of: heading | paragraph | list_item | table_cell" | |
| ) | |
| text: str | |
| level: int = Field(default=0, description="Heading depth; 0 for non-headings") | |
| page: int = Field(default=0, description="1-based page number (PDF only)") | |
| # ββ Chunks ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class Chunk(BaseModel): | |
| """An embedding-ready text chunk with full provenance metadata. | |
| Example:: | |
| Chunk( | |
| chunk_id="c-001", doc_id="d-abc", tenant_id="t-xyz", | |
| text="The property is a semi-detached...", section_type="paragraph", | |
| token_count=87, | |
| ) | |
| """ | |
| chunk_id: str | |
| doc_id: str | |
| tenant_id: str | |
| text: str | |
| section_type: str = Field(default="paragraph") | |
| token_count: int = Field(default=0) | |
| created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) | |
| class ChunkMetadata(BaseModel): | |
| """Subset of Chunk stored as FAISS / LangChain document metadata.""" | |
| chunk_id: str | |
| doc_id: str | |
| tenant_id: str | |
| section_type: str | |
| token_count: int | |
| created_at: str | |
| # ββ Retrieval βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SearchResult(BaseModel): | |
| """A single vector-store hit returned by the retriever.""" | |
| chunk_id: str | |
| doc_id: str | |
| tenant_id: str | |
| text: str | |
| score: float | |
| section_type: str | |
| hierarchy_level: str = Field( | |
| default="paragraph", | |
| description="document | section | paragraph (hierarchical RAG)", | |
| ) | |
| section_title: str | None = Field(default=None, description="Heading / page label when known") | |
| section_id: str | None = Field(default=None, description="Stable section key within the upload") | |
| paragraph_index: int | None = Field(default=None, description="Index within the section for paragraph rows") | |
| parent_chunk_id: str | None = Field( | |
| default=None, | |
| description="Optional link to parent row (reserved for future graph edges)", | |
| ) | |
| source: str | None = Field( | |
| default=None, | |
| description="Optional source label (e.g. original filename when not in DB, such as knowledge base).", | |
| ) | |
| kb: bool | None = Field( | |
| default=None, | |
| description="True when this chunk comes from the local knowledge base corpus.", | |
| ) | |
| kb_path: str | None = Field( | |
| default=None, | |
| description="Optional file path for knowledge base chunks (best-effort; for debugging only).", | |
| ) | |
| chunk_role: str | None = Field( | |
| default=None, | |
| description="boilerplate | reference | exemplar β used to bias retrieval at assembly tier.", | |
| ) | |
| class RerankedResult(SearchResult): | |
| """A retrieval result after lexical / numeric reranking.""" | |
| rerank_score: float = 0.0 | |
| # ββ Generation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class Provenance(BaseModel): | |
| """Source attribution for a single retrieved snippet.""" | |
| doc_id: str | |
| chunk_id: str | |
| score: float | |
| filename: str | None = Field( | |
| default=None, | |
| description="Original upload filename for doc_id (when resolved)", | |
| ) | |
| snippet_preview: str | None = Field( | |
| default=None, | |
| description="Short excerpt from the retrieved chunk for UI citations", | |
| ) | |
| section_hint: str | None = Field( | |
| default=None, | |
| description="Best-effort heading or chunk label for citation display", | |
| ) | |
| class SectionPhotoItem(BaseModel): | |
| """Metadata for one uploaded photo attached to a report section.""" | |
| photo_id: str | |
| original_filename: str | |
| content_type: str | |
| created_at: str | |
| url: str | |
| class SectionPayload(BaseModel): | |
| """Frontend-facing payload for a single generated report section.""" | |
| text: str | |
| confidence: float | |
| provenance: list[Provenance] | |
| cached: bool = False | |
| mode: str = Field(default="generate", description="Mode used: generate | proofread | enhance") | |
| style_profile: WritingStyleProfile | None = Field( | |
| default=None, description="Style profile applied during generation" | |
| ) | |
| ai_level: int | None = Field( | |
| default=None, | |
| description="AI interference level 1β5 used for this section", | |
| ) | |
| ai_percent: int | None = Field( | |
| default=None, | |
| description="AI involvement intensity 0β100 (preferred control; overrides ai_level when provided)", | |
| ) | |
| ai_transparency: dict[str, Any] | None = Field( | |
| default=None, | |
| description="Estimated AI involvement breakdown and plain-language explanation", | |
| ) | |
| photos: list[SectionPhotoItem] = Field( | |
| default_factory=list, | |
| description="Uploaded photos attached to this section (for evidence and DOCX export).", | |
| ) | |
| pipeline: str | None = Field( | |
| default=None, | |
| description="Generation pipeline used: agentic | standard (when available).", | |
| ) | |
| fallback_used: bool | None = Field( | |
| default=None, | |
| description="True when the primary pipeline failed and the section was generated via fallback.", | |
| ) | |
| inspector: dict[str, Any] | None = Field( | |
| default=None, | |
| description=( | |
| "When ``pipeline`` is ``agentic``, structured outputs from the OpenAI inspector " | |
| "tool loop (extraction audit, section plan, condition rating summary, truncated tool trace)." | |
| ), | |
| ) | |
| interference_level: str | None = Field( | |
| default=None, | |
| description="AI interference mode used for this section: minimum | medium | maximum.", | |
| ) | |
| word_count: int | None = Field( | |
| default=None, | |
| description="Approximate word count of the section body at last generation.", | |
| ) | |
| generated_at: str | None = Field( | |
| default=None, | |
| description="ISO 8601 UTC timestamp when this section was last generated or updated by AI.", | |
| ) | |
| class AILevel(int, Enum): | |
| """User-controlled AI interference level (1 = RAG only, 5 = Full AI).""" | |
| rag_only = 1 | |
| """Pure retrieval: the LLM does minimal prose rewriting; text closely mirrors source chunks.""" | |
| light = 2 | |
| """Light touch: minor phrasing improvements, facts preserved verbatim.""" | |
| balanced = 3 | |
| """Default: RAG evidence + moderate style adaptation to the user's voice.""" | |
| strong = 4 | |
| """Strong AI: richer paraphrasing, deeper style matching, more creative bridging.""" | |
| full_ai = 5 | |
| """Full AI: maximum style optimisation, most creative prose; unknowns must be stated as missing/ unverifiable.""" | |
| def ai_level_to_percent(ai_level: int) -> int: | |
| """Map the legacy 1β5 scale to a 0β100 intensity.""" | |
| level = max(1, min(5, int(ai_level))) | |
| return int((level - 1) * 25) | |
| def ai_percent_to_level(ai_percent: int) -> int: | |
| """Map a 0β100 intensity to the legacy 1β5 scale (for backward compatibility).""" | |
| p = max(0, min(100, int(ai_percent))) | |
| # 0..24 -> 1, 25..49 -> 2, 50..74 -> 3, 75..99 -> 4, 100 -> 5 | |
| return min(5, (p // 25) + 1) | |
| class RetrievalLevel(StrEnum): | |
| """User-selected retrieval granularity for hierarchical RAG.""" | |
| document = "document" | |
| """Use document-level embeddings only (whole-file intent / holistic context).""" | |
| section = "section" | |
| """Use section-level embeddings only (mid-granularity scope, e.g. page/part).""" | |
| paragraph = "paragraph" | |
| """Use paragraph/chunk-level embeddings only (fine-grained, precise evidence).""" | |
| class InterferenceLevel(StrEnum): | |
| """Qualitative AI interference selector (maps to fixed internal ``ai_percent`` bands).""" | |
| minimum = "minimum" | |
| """Strict formatter: map notes onto standard structure with almost no invention.""" | |
| medium = "medium" | |
| """Professional editor: light inference and bridging, traceable to notes.""" | |
| maximum = "maximum" | |
| """Expert report writer: full prose within anti-hallucination constraints.""" | |
| INTERFERENCE_LEVEL_AI_PERCENT: dict[InterferenceLevel, int] = { | |
| InterferenceLevel.minimum: 8, | |
| InterferenceLevel.medium: 52, | |
| InterferenceLevel.maximum: 93, | |
| } | |
| class GenerateRequest(BaseModel): | |
| """Request body for POST /reports/{report_id}/generate. | |
| Example:: | |
| GenerateRequest( | |
| template_id="D", | |
| bullets=["Semi-detached house, circa 1965", "Floor area 95 sqm"], | |
| mode=GenerationMode.generate, | |
| ai_level=3, | |
| ) | |
| """ | |
| template_id: str = Field(description="RICS section code, e.g. D, E2, E4, G4") | |
| bullets: list[str] = Field( | |
| description="Inspector messy notes / fact bullets (primary factual source for this section).", | |
| max_length=5000, | |
| ) | |
| messy_notes: list[str] | None = Field( | |
| default=None, | |
| description=( | |
| "Optional alias for ``bullets``: same content as raw inspector notes. " | |
| "When ``bullets`` is empty and this is provided, it is copied into ``bullets`` before validation." | |
| ), | |
| ) | |
| mode: GenerationMode = Field( | |
| default=GenerationMode.generate, | |
| description="generate | proofread | enhance", | |
| ) | |
| ai_percent: int | None = Field( | |
| default=50, | |
| ge=0, | |
| le=100, | |
| description=( | |
| "AI involvement slider value (0β100). " | |
| "0 = pure RAG/no rewriting; 100 = maximum style optimisation. " | |
| "Preferred over ai_level when ``ai_involvement_tier`` is not set." | |
| ), | |
| ) | |
| interference_level: InterferenceLevel | None = Field( | |
| default=None, | |
| description=( | |
| "AI interference level: minimum | medium | maximum. When set, overrides " | |
| "``ai_percent`` and ``ai_level`` for prompts, word targets, and token budgets. " | |
| "Omit to use legacy ``ai_percent`` / ``ai_level`` only." | |
| ), | |
| ) | |
| ai_involvement_tier: str | None = Field( | |
| default=None, | |
| description="Deprecated. Use ``interference_level`` (minimum|medium|maximum). Legacy ``minimal`` maps to ``minimum``.", | |
| exclude=True, | |
| ) | |
| ai_level: AILevel | None = Field( | |
| default=AILevel.balanced, | |
| description=( | |
| "Legacy AI interference level 1β5 (deprecated in favour of ai_percent). " | |
| "If both ai_percent and ai_level are provided, ai_percent wins." | |
| ), | |
| ) | |
| retrieval_level: RetrievalLevel = Field( | |
| default=RetrievalLevel.paragraph, | |
| description=( | |
| "Hierarchical RAG retrieval granularity: document | section | paragraph. " | |
| "The backend will retrieve and generate strictly from this level only." | |
| ), | |
| ) | |
| force_regenerate: bool = Field( | |
| default=False, | |
| description="Skip cache and force a fresh LLM call", | |
| ) | |
| strict_uploaded_only: bool = Field( | |
| default=False, | |
| description=( | |
| "Hard isolation mode. When true: use ONLY documents explicitly attached to this report " | |
| "(primary_document_id + reference_document_ids + runtime index), and the current bullets. " | |
| "Do not use KB/Behrang corpora, do not use the standard-paragraph Word boilerplate, and " | |
| "avoid tenant-wide style/profile reuse." | |
| ), | |
| ) | |
| reference_document_ids: list[str] = Field( | |
| default_factory=list, | |
| description=( | |
| "UUIDs of additional indexed PDFs/DOCX to prioritise after the report's primary upload " | |
| "(e.g. exemplar RICS reports such as a Beh Rangβstyle reference). " | |
| "These act as contextual ``uploaded_reports`` for retrieval alongside the primary document." | |
| ), | |
| ) | |
| draft_paragraph: str | None = Field( | |
| default=None, | |
| max_length=5000, | |
| description=( | |
| "Optional paragraph the surveyor has drafted or edited; the model may mirror its tone, " | |
| "rhythm, and structure while keeping facts grounded in bullets and retrieved evidence." | |
| ), | |
| ) | |
| template_ids: list[str] | None = Field( | |
| default=None, | |
| max_length=64, | |
| description=( | |
| "Optional extra section codes to process in the same background job " | |
| "(generate, proofread, or enhance). Parallel when ENABLE_ASYNC_PIPELINE=true, " | |
| "otherwise sequential. ``template_id`` is always included." | |
| ), | |
| ) | |
| bullets_by_section: dict[str, list[str]] | None = Field( | |
| default=None, | |
| description=( | |
| "Per-section bullets when generating multiple sections via ``template_ids``. " | |
| "Sections omitted here reuse the top-level ``bullets`` list." | |
| ), | |
| ) | |
| def _merge_aliases_and_interference_level(cls, data: Any) -> Any: | |
| if not isinstance(data, dict): | |
| return data | |
| d = dict(data) | |
| # Optional alias: messy_notes β bullets when bullets omitted | |
| bullets = d.get("bullets") | |
| if (not bullets) and d.get("messy_notes") is not None: | |
| mn = d["messy_notes"] | |
| if isinstance(mn, list): | |
| d["bullets"] = [str(x).strip() for x in mn if str(x).strip()] | |
| elif isinstance(mn, str): | |
| d["bullets"] = [x.strip() for x in mn.splitlines() if x.strip()] | |
| # Legacy ai_involvement_tier β interference_level | |
| if d.get("interference_level") is None and d.get("ai_involvement_tier") is not None: | |
| leg = str(d.get("ai_involvement_tier")).strip().lower() | |
| if leg == "minimal": | |
| leg = "minimum" | |
| d["interference_level"] = leg | |
| raw_il = d.get("interference_level") | |
| if raw_il is None: | |
| return d | |
| try: | |
| tier = raw_il if isinstance(raw_il, InterferenceLevel) else InterferenceLevel(str(raw_il).strip().lower()) | |
| except Exception as exc: # noqa: BLE001 | |
| raise ValueError( | |
| "interference_level must be exactly one of: minimum, medium, maximum." | |
| ) from exc | |
| pct = INTERFERENCE_LEVEL_AI_PERCENT[tier] | |
| return {**d, "ai_percent": pct, "ai_level": ai_percent_to_level(pct)} | |
| def _normalise_bullets(cls, v: object) -> list[str]: | |
| # Accept empty lists (the UI may intentionally omit bullets so the user | |
| # can fill missing sections after generation). We do not hard-fail here | |
| # because the backend may choose to persist a blank section instead. | |
| if v is None: | |
| return [] | |
| if isinstance(v, str): | |
| # Defensive: allow a newline-joined string payload. | |
| raw = [x.strip() for x in v.splitlines()] | |
| elif isinstance(v, list): | |
| raw = [str(x).strip() for x in v] | |
| else: | |
| return [] | |
| # Remove empty items but do NOT enforce a strict max here; the service | |
| # layer will clamp/dedupe to its operational limit and can surface a | |
| # user-friendly warning in the UI if desired. | |
| return [x for x in raw if x] | |
| def _normalise_reference_document_ids(cls, v: object) -> list[str]: | |
| if v is None: | |
| return [] | |
| if not isinstance(v, list): | |
| return [] | |
| return [str(x).strip() for x in v if str(x).strip()] | |
| def _normalise_ai_percent(cls, v: object) -> int | None: | |
| if v is None: | |
| return None | |
| try: | |
| p = int(v) # allow numeric strings | |
| except Exception: # noqa: BLE001 | |
| return None | |
| return max(0, min(100, p)) | |
| def _normalise_ai_level(cls, v: object) -> AILevel | None: | |
| if v is None: | |
| return None | |
| try: | |
| level = int(v) | |
| except Exception: # noqa: BLE001 | |
| return None | |
| level = max(1, min(5, level)) | |
| return AILevel(level) | |
| def _coerce_ai_level_when_percent_missing(cls, v: AILevel | None, info: Any) -> AILevel: | |
| # If ai_percent was explicitly set (including 0), we keep ai_level as-is | |
| # and let the service treat ai_percent as the source of truth. | |
| data = getattr(info, "data", {}) or {} | |
| if data.get("ai_percent", None) is None: | |
| # ai_percent missing β ensure ai_level has a sensible default | |
| return v or AILevel.balanced | |
| return v or AILevel(ai_percent_to_level(int(data["ai_percent"]))) | |
| def _normalise_draft_paragraph(cls, v: str | None) -> str | None: | |
| if v is None: | |
| return None | |
| s = v.strip() | |
| return s if s else None | |
| def _validate_bullets(cls, v: list[str]) -> list[str]: | |
| """Validate bullets (may be empty). | |
| Empty bullets are allowed because generation can intentionally persist a | |
| blank section (see `app/services/generation.py`), enabling the UI to | |
| prompt the user to fill missing sections inline. | |
| """ | |
| oversized = [i for i, b in enumerate(v) if len(b) > 2000] | |
| if oversized: | |
| raise ValueError( | |
| f"Bullet(s) at index {oversized} exceed 2000 characters. " | |
| "Split long notes into shorter bullets." | |
| ) | |
| return v | |
| def _require_valid_template_id(cls, v: str) -> str: | |
| from app.templates.registry import ALL_VALID_SECTION_CODES | |
| code = v.strip() | |
| if not code: | |
| raise ValueError("template_id must not be blank.") | |
| if code not in ALL_VALID_SECTION_CODES: | |
| valid = ", ".join(sorted(ALL_VALID_SECTION_CODES)) | |
| raise ValueError( | |
| f"template_id {code!r} is not a valid RICS section code " | |
| f"(depends on survey product tier 1/2/3). Known codes across all tiers: {valid}" | |
| ) | |
| return code | |
| class ExtractNotesResponse(BaseModel): | |
| """Response from POST /extract-notes β parsed raw text lines from an uploaded doc. | |
| Also reports the survey tier the *content* of the notes appears to come from, | |
| so the frontend can warn when the user has uploaded e.g. Level 3 inspection | |
| detail onto a Level 1 report (where it would otherwise be silently misrouted | |
| into the Introduction section). | |
| """ | |
| filename: str | |
| lines: list[str] | |
| line_count: int | |
| # Tier the notes content reads as. 0 means the classifier had insufficient signal | |
| # (typically very short notes) β frontend should treat 0 as "unknown" and skip | |
| # the mismatch modal rather than surface a meaningless warning. | |
| predicted_survey_level: int = Field(0, ge=0, le=3) | |
| confidence: float = Field(0.0, ge=0.0, le=1.0) | |
| rationale: str = "" | |
| class GenerateResponse(BaseModel): | |
| report_id: str | |
| status: str | |
| message: str | |
| workflow_id: str | None = Field( | |
| default=None, | |
| description="Temporal workflow id when ENABLE_TEMPORAL_WORKFLOW=true and start succeeded.", | |
| ) | |
| class RuntimeRagSyncBody(BaseModel): | |
| """Body for POST /reports/{report_id}/rag/runtime/sync β push edited text into the index.""" | |
| section_code: str = Field(description="RICS section code, same as template_id / generate.template_id") | |
| text: str = Field(description="Edited section body; becomes source of truth for retrieval") | |
| section_title: str | None = Field( | |
| default=None, | |
| description="Optional label for overview metadata (defaults to section_code)", | |
| ) | |
| def _valid_section_code(cls, v: str) -> str: | |
| from app.templates.registry import ALL_VALID_SECTION_CODES | |
| code = v.strip() | |
| if not code: | |
| raise ValueError("section_code must not be blank.") | |
| if code not in ALL_VALID_SECTION_CODES: | |
| valid = ", ".join(sorted(ALL_VALID_SECTION_CODES)) | |
| raise ValueError( | |
| f"section_code {code!r} is not a valid RICS section code across product tiers. Valid codes: {valid}" | |
| ) | |
| return code | |
| class RuntimeRagSyncResponse(BaseModel): | |
| """Result of a runtime RAG re-index operation.""" | |
| virtual_doc_id: str | |
| chunks_indexed: int | |
| class ReportStatusResponse(BaseModel): | |
| report_id: str | |
| status: str | |
| created_at: datetime | |
| updated_at: datetime | |
| error_message: str | None = None | |
| survey_level: int | None = Field( | |
| default=None, | |
| description="RICS Home Survey product tier used for this report job (1 / 2 / 3).", | |
| ) | |
| class SectionsResponse(BaseModel): | |
| report_id: str | |
| sections: dict[str, SectionPayload] | |
| # Hard cap for inline-edited section bodies. | |
| # Counted in UTF-8 bytes (not characters) so multibyte text is bounded too. | |
| SECTION_TEXT_MAX_BYTES: int = 200 * 1024 # 200 KB | |
| class SectionTextUpdateBody(BaseModel): | |
| """Body for ``PATCH /reports/{report_id}/sections/{section_code}``. | |
| Persists an inline-edited section body. Empty string is accepted (the user | |
| may want to clear the section). The 200 KB cap is enforced **inside the | |
| endpoint** rather than as a Pydantic ``max_length`` constraint so we can | |
| return HTTP 413 (Payload Too Large) instead of Pydantic's default 422. | |
| Note: This body has exactly one field. Do not add new fields here without | |
| a paired backend change in ``update_section_text``. | |
| """ | |
| text: str = Field( | |
| ..., | |
| description=( | |
| "New section body (UTF-8 string; empty string is allowed to clear the section). " | |
| f"The endpoint rejects bodies whose UTF-8 byte length exceeds {SECTION_TEXT_MAX_BYTES} bytes " | |
| "(200 KB) with HTTP 413." | |
| ), | |
| ) | |
| # ββ Style profile endpoint βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class StyleProfileResponse(BaseModel): | |
| """Response for GET /tenants/{tenant_id}/style-profile.""" | |
| tenant_id: str | |
| profile: WritingStyleProfile | |
| analysed_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) | |
| # ββ Similarity / deduplication (notes vs library & other sections) ββββββββββββ | |
| class SimilarContentRequest(BaseModel): | |
| """Body for POST /content/similar β find overlapping uploads and draft notes.""" | |
| text: str = Field(..., max_length=50_000, description="Paragraph or bullet block to check") | |
| section_code: str | None = Field( | |
| default=None, | |
| description="Current RICS section code (excluded from peer overlap scan)", | |
| ) | |
| limit: int = Field(default=8, ge=1, le=20, description="Max library matches to return") | |
| min_relevance_percent: float = Field( | |
| default=28.0, | |
| ge=0.0, | |
| le=100.0, | |
| description="Drop library hits weaker than this %% of the strongest match in the batch", | |
| ) | |
| peer_sections: dict[str, str] = Field( | |
| default_factory=dict, | |
| description="Other sections' draft notes: section_code β bullets text (for duplicate detection)", | |
| ) | |
| exclude_document_ids: list[str] = Field( | |
| default_factory=list, | |
| description=( | |
| "Omit these uploaded document UUIDs from library matches β e.g. the primary survey file " | |
| "so results highlight separate guidance / regulation uploads" | |
| ), | |
| ) | |
| def _normalize_exclude_ids(cls, v: list[str]) -> list[str]: | |
| if len(v) > 48: | |
| raise ValueError("exclude_document_ids: at most 48 entries") | |
| return [x.strip() for x in v if x.strip()] | |
| class LibrarySimilarMatch(BaseModel): | |
| """A chunk from the tenant's indexed uploads that is semantically close to the query text.""" | |
| chunk_id: str | |
| document_id: str | |
| filename: str | None = None | |
| snippet: str = Field(description="Short excerpt from the indexed document") | |
| relevance_percent: float = Field(description="0β100, relative to strongest hit in this response") | |
| section_type: str = "paragraph" | |
| class DraftOverlapMatch(BaseModel): | |
| """Another section's notes (or a line within them) that overlaps the submitted text.""" | |
| other_section_code: str | |
| overlap_kind: str = Field(description="line | block") | |
| similarity: float = Field(ge=0.0, le=1.0, description="Lexical similarity (Jaccard on tokens)") | |
| your_preview: str = Field(description="The line or excerpt from your current text") | |
| other_preview: str = Field(description="Matching line or excerpt from the other section") | |
| class SimilarContentResponse(BaseModel): | |
| """Similarity scan results for informed deduplication / refresh decisions.""" | |
| library_matches: list[LibrarySimilarMatch] | |
| draft_overlaps: list[DraftOverlapMatch] | |
| message: str = "" | |
| # ββ Canonical paragraph rollout (library-wide semantic upgrade) βββββββββββββββ | |
| class CanonicalRolloutRequest(BaseModel): | |
| """Body for POST /content/canonical-scan β find indexed chunks to upgrade.""" | |
| canonical_text: str = Field( | |
| ..., | |
| max_length=50_000, | |
| description="Improved standard paragraph to match against the tenant index", | |
| ) | |
| limit: int = Field(default=24, ge=1, le=100, description="Max candidate chunks after filters") | |
| min_relevance_percent: float = Field( | |
| default=28.0, | |
| ge=0.0, | |
| le=100.0, | |
| description="Relative to strongest hit in the batch (same idea as /content/similar)", | |
| ) | |
| min_jaccard_vs_canonical: float = Field( | |
| default=0.06, | |
| ge=0.0, | |
| le=1.0, | |
| description="Minimum token Jaccard between chunk text and canonical (reduces false positives)", | |
| ) | |
| document_ids: list[str] | None = Field( | |
| default=None, | |
| description="If set, only consider chunks from these uploaded document UUIDs", | |
| ) | |
| exclude_document_ids: list[str] = Field( | |
| default_factory=list, | |
| description="Exclude these document UUIDs from results", | |
| ) | |
| adapt_with_llm: bool = Field( | |
| default=False, | |
| description="If true and OPENAI_API_KEY is set, merge canonical with doc-specific facts via LLM", | |
| ) | |
| def _scope_document_ids(cls, v: list[str] | None) -> list[str] | None: | |
| if v is None: | |
| return None | |
| if len(v) > 200: | |
| raise ValueError("document_ids: at most 200 entries") | |
| return [x.strip() for x in v if x.strip()] | |
| def _normalize_exclude_rollout(cls, v: list[str]) -> list[str]: | |
| if len(v) > 200: | |
| raise ValueError("exclude_document_ids: at most 200 entries") | |
| return [x.strip() for x in v if x.strip()] | |
| class CanonicalRolloutMatch(BaseModel): | |
| """One indexed chunk that semantically matches the canonical paragraph.""" | |
| chunk_id: str | |
| document_id: str | |
| filename: str | None = None | |
| original_snippet: str = Field(description="Full chunk text (may be long)") | |
| relevance_percent: float | |
| jaccard_vs_canonical: float = Field(ge=0.0, le=1.0) | |
| compatibility: str = Field( | |
| description="high | review | low β auto-apply only for high after human policy review", | |
| ) | |
| proposed_replacement: str | |
| preservation_note: str = Field( | |
| default="", | |
| description="Numbers or phrases in the chunk to preserve if adapting manually", | |
| ) | |
| class CanonicalRolloutResponse(BaseModel): | |
| """Candidates for upgrading library text to a canonical paragraph.""" | |
| matches: list[CanonicalRolloutMatch] | |
| message: str = "" | |
| class CanonicalApplyRequest(BaseModel): | |
| """Body for POST /content/canonical-apply β rewrite one chunk in a .docx and re-ingest.""" | |
| document_id: str | |
| chunk_id: str | |
| replacement_text: str = Field(..., max_length=50_000) | |
| confirm_destructive_docx: bool = Field( | |
| default=False, | |
| description="Must be true: apply rebuilds the .docx as plain paragraphs (layout may change)", | |
| ) | |
| class CanonicalApplyResponse(BaseModel): | |
| """After apply: file updated on disk and vector index refreshed for that document.""" | |
| document_id: str | |
| chunk_id: str | |
| filename: str | |
| detail: str | |
| chunks_indexed: int | |
| class DocumentDeleteResponse(BaseModel): | |
| """Confirmation after removing a document from the library.""" | |
| document_id: str | |
| removed: bool = True | |
| detail: str = "" | |
| # ββ Dev / test endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestIngestRequest(BaseModel): | |
| """Body for POST /test/ingest (dev-only endpoint).""" | |
| file_path: str = Field(description="Absolute path to a sample file on disk") | |
| tenant_id: str = Field(default="test_tenant") | |