File size: 37,932 Bytes
dc1b199
 
faa8fb3
 
 
dc1b199
879e4e0
0136798
 
 
faa8fb3
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377c1bc
 
 
 
dc1b199
 
 
 
 
 
 
 
 
 
 
 
 
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1e2ff8
b76f199
 
 
 
a1e2ff8
 
 
 
 
 
b76f199
 
 
a1e2ff8
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
faa8fb3
dc1b199
 
 
32c4506
dc1b199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b42403
 
 
 
dc1b199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eda3d74
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
b76f199
 
 
 
 
 
 
 
 
 
dc1b199
 
 
 
 
 
 
0136798
 
 
 
eda3d74
 
 
 
b76f199
 
 
 
faa8fb3
eda3d74
 
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
879e4e0
 
 
 
 
 
 
 
 
 
 
 
0136798
 
 
 
 
 
 
 
 
 
 
 
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
879e4e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
 
 
 
 
3c31a2a
dc1b199
0136798
 
dc1b199
 
 
3c31a2a
dc1b199
879e4e0
751f4aa
dc1b199
879e4e0
 
 
 
 
 
 
0136798
 
 
 
b76f199
 
 
 
 
 
 
879e4e0
 
 
 
 
 
 
 
 
b76f199
 
879e4e0
 
 
 
 
b76f199
0136798
b76f199
 
 
 
 
 
 
 
 
 
 
0136798
dc1b199
 
 
 
3f6016e
 
 
 
 
 
 
 
 
b76f199
 
 
 
879e4e0
 
b76f199
 
 
 
a1e2ff8
b76f199
 
 
 
 
732b14f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b76f199
879e4e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751f4aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
3c31a2a
 
2cfebbc
 
 
 
 
 
 
a1e2ff8
 
 
 
 
 
3c31a2a
 
 
 
 
b76f199
 
3c31a2a
 
 
b76f199
 
3c31a2a
b76f199
 
3c31a2a
 
 
dc1b199
0136798
a1e2ff8
 
 
 
 
 
 
0136798
 
 
 
a1e2ff8
 
 
 
 
 
0136798
 
dc1b199
 
 
 
732b14f
 
 
 
dc1b199
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
 
 
 
eda3d74
b76f199
 
 
 
dc1b199
 
 
 
 
 
 
a1e2ff8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0136798
 
 
 
 
 
 
faa8fb3
0136798
 
0908f70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
569bceb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0908f70
 
 
 
 
 
 
 
dc1b199
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
"""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.",
    )

    @field_validator("document_ids")
    @classmethod
    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)",
    )

    @field_validator("document_ids")
    @classmethod
    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."
        ),
    )

    @model_validator(mode="before")
    @classmethod
    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)}

    @field_validator("bullets", mode="before")
    @classmethod
    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]

    @field_validator("reference_document_ids", mode="before")
    @classmethod
    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()]

    @field_validator("ai_percent", mode="before")
    @classmethod
    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))

    @field_validator("ai_level", mode="before")
    @classmethod
    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)

    @field_validator("ai_level")
    @classmethod
    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"])))

    @field_validator("draft_paragraph")
    @classmethod
    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

    @field_validator("bullets")
    @classmethod
    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

    @field_validator("template_id")
    @classmethod
    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)",
    )

    @field_validator("section_code")
    @classmethod
    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"
        ),
    )

    @field_validator("exclude_document_ids")
    @classmethod
    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",
    )

    @field_validator("document_ids")
    @classmethod
    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()]

    @field_validator("exclude_document_ids")
    @classmethod
    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")