File size: 9,134 Bytes
ae992b4
 
fef5f29
ae992b4
 
 
 
fef5f29
2b326e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ae992b4
fef5f29
ec196c2
a9f36a6
e01e2f8
 
ec196c2
 
fef5f29
ae992b4
 
 
 
 
 
 
 
 
 
 
fef5f29
 
ae992b4
fef5f29
 
 
2b326e3
 
ae992b4
2b326e3
ae992b4
fef5f29
ae992b4
eb03836
fef5f29
 
 
 
ae992b4
fef5f29
ae992b4
fef5f29
ae992b4
fef5f29
 
 
ae992b4
fef5f29
 
 
 
ae992b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fef5f29
 
 
 
 
 
 
 
 
 
 
 
 
ae992b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fef5f29
 
 
 
 
 
ae992b4
ec196c2
ae992b4
 
 
 
fef5f29
 
ec196c2
ae992b4
fef5f29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ec196c2
fef5f29
 
ec196c2
eb03836
 
ec196c2
 
 
 
 
 
 
 
 
 
 
eb03836
ec196c2
 
 
 
b3ea57a
 
 
 
 
 
ec196c2
 
b3ea57a
 
 
 
ec196c2
31816a1
fef5f29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import json
import pathlib
from enum import StrEnum
from typing import Any, Dict, List, Optional, Set

from pydantic import BaseModel


# ── Authorized Representatives lookup map ──────────────────────────────────────
# Keys: full name (displayed in dropdown)
# Values: dict of data_key β†’ value for the other rep fields
REPRESENTATIVES: Dict[str, Dict[str, str]] = {
    "Ahmad Royhan": {
        "rep_id":      "3174050905990008",
        "rep_address": "Jl. Kramat 1 No 15, Kwitang",
        "rep_city":    "Jakarta Selatan",
    },
    # Add more representatives here:
    # "Full Name": {
    #     "rep_id":      "...",
    #     "rep_address": "...",
    #     "rep_city":    "...",
    # },
}


class DocumentType(StrEnum):
    COVER_LETTER_SCHENGEN = "cover_letter_schengen"
    COVER_LETTER_UK       = "cover_letter_uk"
    LETTER_OF_AUTHORIZATION = "letter_of_authorization"
    LETTER_OF_AUTHORIZATION_MINOR = "letter_of_authorization_minor"
    # INVITATION_LETTER    = "invitation_letter"
    # BANK_REFERENCE       = "bank_reference"


class SectionConfig(BaseModel):
    """Metadata for one form section, including explicit display order."""

    key: str
    title: str
    order: int = 0
    # data_keys whose widgets are rendered as read-only in this section
    disabled_keys: Set[str] = set()


class FieldSchema(BaseModel):
    """Describes one editable field for a document type."""

    widget_key: str               # Streamlit session-state key
    data_key: str                 # Key inside the data dict
    label: str
    placeholder: str = ""
    description: Optional[str] = None   # Sent to /prefill-document; falls back to placeholder
    field_type: str = "text_input"      # "text_input" | "text_area" | "select"
    options: Optional[List[str]] = None  # For field_type="select"
    section: str = "general"            # Maps to a SectionConfig.key
    nested_under: Optional[str] = None  # e.g. "personal_details"
    order: int = 0                      # Explicit render position within the section; lower = earlier
    static_value: Optional[str] = None  # Pre-seeded value; applied as fallback when DB prefill returns null


class DocumentSchema(BaseModel):
    """Full metadata for a document type.

    Drives form rendering, session-state key management, and API calls.
    Loaded from a JSON file in the src/schemas/ directory.
    """

    document_type: DocumentType
    title: str
    icon: str = "πŸ“"
    sections: List[SectionConfig]
    fields: List[FieldSchema]
    has_trip_type: bool = False
    has_group_members: bool = False

    def ordered_sections(self) -> List[SectionConfig]:
        """Return sections sorted by SectionConfig.order (stable sort)."""
        return sorted(self.sections, key=lambda s: s.order)

    def section_title(self, key: str) -> str:
        """Return the display title for a section key."""
        for s in self.sections:
            if s.key == key:
                return s.title
        return key

    def section_disabled_keys(self, key: str) -> Set[str]:
        """Return the disabled_keys set for a section key."""
        for s in self.sections:
            if s.key == key:
                return s.disabled_keys
        return set()

    def fields_in_section(self, section: str) -> List[FieldSchema]:
        """Return fields belonging to section, sorted by FieldSchema.order."""
        return sorted(
            [f for f in self.fields if f.section == section],
            key=lambda f: f.order,
        )

    def top_level_fields(self, section: str) -> List[FieldSchema]:
        """Return non-nested fields in section, sorted by order."""
        return sorted(
            [f for f in self.fields if f.section == section and f.nested_under is None],
            key=lambda f: f.order,
        )

    def widget_keys(self) -> List[str]:
        """All Streamlit widget keys that must be cleared on undo/redo."""
        keys: List[str] = []
        if self.has_trip_type:
            keys.append(f"{self.document_type}_trip_type")
        keys.extend(f.widget_key for f in self.fields)
        if self.has_group_members:
            keys.append(f"{self.document_type}_group_members_editor")
        return keys

    def nested_fields(self, nested_under: str) -> List[FieldSchema]:
        return [f for f in self.fields if f.nested_under == nested_under]


# ── JSON-based schema loader ────────────────────────────────────────────────────

_SCHEMA_DIR: pathlib.Path = pathlib.Path(__file__).parent / "schemas"
_REPRESENTATIVES_MARKER: str = "$REPRESENTATIVES"


def _resolve_options(options: Any) -> Optional[List[str]]:
    """Replace the $REPRESENTATIVES marker with the actual representative names list."""
    if options == _REPRESENTATIVES_MARKER:
        return list(REPRESENTATIVES.keys())
    return options  # type: ignore[return-value]


def _load_document_schema(path: pathlib.Path) -> DocumentSchema:
    """Load and validate a DocumentSchema from a JSON file."""
    raw: Dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
    # Resolve dynamic markers in field options before Pydantic validation
    for field_data in raw.get("fields", []):
        if "options" in field_data:
            field_data["options"] = _resolve_options(field_data["options"])
    return DocumentSchema.model_validate(raw)


def _build_registry() -> Dict[str, DocumentSchema]:
    """Scan the schemas/ directory and build DOCUMENT_REGISTRY from JSON files."""
    registry: Dict[str, DocumentSchema] = {}
    for json_file in sorted(_SCHEMA_DIR.glob("*.json")):
        schema: DocumentSchema = _load_document_schema(json_file)
        registry[schema.document_type] = schema
    return registry


DOCUMENT_REGISTRY: Dict[str, DocumentSchema] = _build_registry()


# ── API request models ─────────────────────────────────────

class PrefillDocumentRequest(BaseModel):
    """
    Sent to /prefill-document.
    `structure` describes the fields the LLM should map from DB data.
    `sections` (optional) β€” if provided, the response is nested per section:
        { "sections": [{key, title, order, fields: [{key, label, order, value}]}],
          "_missing_required": [...] }
    If `sections` is omitted, falls back to the old flat dict response.
    """
    application_id: int
    structure: List[Dict[str, Any]]
    sections: Optional[List[Dict[str, Any]]] = None  # [{key, title, order}, ...]


class GenerateDocumentRequest(BaseModel):
    """
    Sent to /generate-document.
    `doc_data` carries all form fields as-is; the backend merges them on top
    of the DB data and generates the document.
    Adding a new document type requires no change to this model.
    """
    application_id: int
    document_name: str  # value from DocumentType
    doc_data: Optional[Dict[str, Any]] = None


class DocChatRequest(BaseModel):
    """Sent to /generate-document/chat (new stateless draft-revision flow)."""
    query: str
    history: List[Dict[str, str]]
    session_uuid: Optional[str] = None
    current_document: Optional[Dict[str, Any]] = None   # structured {title, city_date, main}
    current_document_content: str = ""                  # legacy plain-markdown fallback
    structure: Optional[List[Dict[str, Any]]] = None


class GenerateDraftRequest(BaseModel):
    """
    Sent to /generate-document/draft.
    Generates a Markdown draft without creating a Google Doc.
    `doc_type` and `structure` are stored on the frontend for later handover to the core API.
    """
    doc_type: str
    data: Dict[str, Any]
    application_id: Optional[int] = None  # used by the API to load persona from DB
    structure: Optional[List[Dict[str, Any]]] = None  # field definitions for core API handover
    session_uuid: Optional[str] = None


class DraftField(BaseModel):
    """A single structured field from /generate-document/draft."""
    style: str
    content: str


class ExportDocumentRequest(BaseModel):
    """Sent to /export-document once the user is satisfied with the draft."""
    document_content: Optional[str] = None  # legacy
    title: Optional[DraftField] = None
    city_date: Optional[DraftField] = None
    main: Optional[DraftField] = None
    export_format: str = "gdocs"
    document_url: Optional[str] = None  # existing Google Doc URL; if set, AI edits instead of creating


# ── Shared sub-models (used for type hints / validation internally) ──

class PersonalDetails(BaseModel):
    name: Optional[str] = None
    dob: Optional[str] = None
    nationality: Optional[str] = None
    occupation: Optional[str] = None
    passport_number: Optional[str] = None


class GroupMember(BaseModel):
    relationship: Optional[str] = None
    name: Optional[str] = None
    dob: Optional[str] = None
    occupation: Optional[str] = None
    nationality: Optional[str] = None
    passport_number: Optional[str] = None