Muhammad Risqi Firdaus commited on
Commit
ec196c2
Β·
1 Parent(s): 9dbe87e

feat: cover letter--schengen

Browse files
src/document_generator_models.py CHANGED
@@ -8,9 +8,10 @@ from enum import StrEnum
8
  # Each value must match the string the backend expects.
9
 
10
  class DocumentType(StrEnum):
11
- COVER_LETTER = "cover_letter"
12
- # INVITATION_LETTER = "invitation_letter"
13
- # BANK_REFERENCE = "bank_reference"
 
14
 
15
 
16
  # ── Field & schema descriptors ─────────────────────────────
@@ -64,36 +65,36 @@ class DocumentSchema(BaseModel):
64
  # To add a new document type:
65
  # 1. Add its value to DocumentType above.
66
  # 2. Add a DocumentSchema entry here.
67
- # 3. Add a tab + form in 03_Document_Generator.py.
68
- # The backend receives `document_name` and generates the SQL via LLM.
69
 
70
  DOCUMENT_REGISTRY: Dict[str, DocumentSchema] = {
71
- DocumentType.COVER_LETTER: DocumentSchema(
72
- document_type=DocumentType.COVER_LETTER,
73
  title="Schengen Visa Cover Letter",
74
  icon="πŸ“",
75
  sections={
76
  "trip": "🌍 Trip Details",
77
- "applicant": "πŸ‘€ Applicant Details",
78
  "contact": "πŸ“‹ Contact & Financials",
79
  },
80
  fields=[
81
  # ── Trip section ───────────────────────────────
82
- FieldSchema(widget_key="cl_country", data_key="country", label="Country of Embassy", placeholder="e.g. Germany", section="trip"),
83
- FieldSchema(widget_key="cl_city", data_key="city_of_application", label="City of Application", placeholder="e.g. Jakarta", section="trip"),
84
- FieldSchema(widget_key="cl_purpose", data_key="purpose", label="Purpose of Trip", placeholder="e.g. tourism", section="trip"),
85
- FieldSchema(widget_key="cl_main_dest", data_key="destination_country", label="Main Destination", placeholder="e.g. Germany", section="trip"),
86
- FieldSchema(widget_key="cl_event", data_key="event", label="Event", placeholder="e.g. personal vacation", section="trip"),
87
- FieldSchema(widget_key="cl_other_dest", data_key="other_dest", label="Other Destinations", placeholder="e.g. France and Italy", section="trip"),
88
- FieldSchema(widget_key="cl_start", data_key="start_date", label="Travel Start Date", placeholder="e.g. 2026-05-01", section="trip"),
89
- FieldSchema(widget_key="cl_end", data_key="end_date", label="Travel End Date", placeholder="e.g. 2026-05-14", section="trip"),
90
- FieldSchema(widget_key="cl_duration", data_key="duration", label="Duration", placeholder="e.g. 14 days", section="trip"),
 
91
  # ── Applicant section (nested under personal_details) ──
92
- FieldSchema(widget_key="cl_pd_name", data_key="name", label="Full Name", placeholder="e.g. John Doe", section="applicant", nested_under="personal_details"),
93
- FieldSchema(widget_key="cl_pd_dob", data_key="dob", label="Date of Birth", placeholder="e.g. 1st Jan 1990", section="applicant", nested_under="personal_details"),
94
- FieldSchema(widget_key="cl_pd_nationality", data_key="nationality", label="Nationality", placeholder="e.g. Indonesian", section="applicant", nested_under="personal_details"),
95
- FieldSchema(widget_key="cl_pd_occupation", data_key="occupation", label="Occupation", placeholder="e.g. Engineer", section="applicant", nested_under="personal_details"),
96
- FieldSchema(widget_key="cl_pd_passport_number", data_key="passport_number", label="Passport Number", placeholder="e.g. A1234567", section="applicant", nested_under="personal_details"),
97
  # ── Contact section ────────────────────────────
98
  FieldSchema(widget_key="cl_trip_highlight", data_key="trip_highlight", label="Trip Highlight", placeholder="Key highlights of your trip...", field_type="text_area", section="contact"),
99
  FieldSchema(widget_key="cl_contact_email", data_key="contact_email", label="Contact Email", placeholder="e.g. john@email.com", section="contact"),
@@ -111,12 +112,12 @@ DOCUMENT_REGISTRY: Dict[str, DocumentSchema] = {
111
 
112
  class PrefillDocumentRequest(BaseModel):
113
  """
114
- Sent to /prefill-document.
115
- Backend uses `document_name` to determine which SQL to run (via LLM)
116
- for the given application_id.
117
  """
118
  application_id: int
119
- document_name: str # value from DocumentType
120
 
121
 
122
  class GenerateDocumentRequest(BaseModel):
@@ -132,13 +133,31 @@ class GenerateDocumentRequest(BaseModel):
132
 
133
 
134
  class DocChatRequest(BaseModel):
135
- """Sent to /generate-document/chat."""
136
  query: str
137
  history: List[Dict[str, str]]
138
- language: str = "ID"
139
- session_uuid: str
140
- application_id: Optional[int] = None
141
- doc_data: Optional[Dict[str, Any]] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
 
144
  # ── Shared sub-models (used for type hints / validation internally) ──
 
8
  # Each value must match the string the backend expects.
9
 
10
  class DocumentType(StrEnum):
11
+ COVER_LETTER_SCHENGEN = "cover_letter_schengen"
12
+ # COVER_LETTER_UK = "cover_letter_uk"
13
+ # INVITATION_LETTER = "invitation_letter"
14
+ # BANK_REFERENCE = "bank_reference"
15
 
16
 
17
  # ── Field & schema descriptors ─────────────────────────────
 
65
  # To add a new document type:
66
  # 1. Add its value to DocumentType above.
67
  # 2. Add a DocumentSchema entry here.
68
+ # The frontend auto-generates a tab and form from the schema β€” no UI code needed.
 
69
 
70
  DOCUMENT_REGISTRY: Dict[str, DocumentSchema] = {
71
+ DocumentType.COVER_LETTER_SCHENGEN: DocumentSchema(
72
+ document_type=DocumentType.COVER_LETTER_SCHENGEN,
73
  title="Schengen Visa Cover Letter",
74
  icon="πŸ“",
75
  sections={
76
  "trip": "🌍 Trip Details",
77
+ "applicant": "πŸ‘€ Main Applicant Details",
78
  "contact": "πŸ“‹ Contact & Financials",
79
  },
80
  fields=[
81
  # ── Trip section ───────────────────────────────
82
+ # data_key matches the prefill response keys returned by /prefill-document
83
+ FieldSchema(widget_key="cl_country", data_key="country", label="Country of Embassy", placeholder="e.g. Germany", section="trip"),
84
+ FieldSchema(widget_key="cl_city", data_key="city", label="City of Application", placeholder="e.g. Jakarta", section="trip"),
85
+ FieldSchema(widget_key="cl_purpose", data_key="purpose", label="Purpose of Trip", placeholder="e.g. tourism", section="trip"),
86
+ FieldSchema(widget_key="cl_main_dest", data_key="main_dest", label="Main Destination", placeholder="e.g. Germany", section="trip"),
87
+ FieldSchema(widget_key="cl_event", data_key="event", label="Event", placeholder="e.g. personal vacation", section="trip"),
88
+ FieldSchema(widget_key="cl_other_dest", data_key="other_dest", label="Other Destinations", placeholder="e.g. France and Italy", section="trip"),
89
+ FieldSchema(widget_key="cl_start", data_key="start", label="Travel Start Date", placeholder="e.g. 2026-05-01", section="trip"),
90
+ FieldSchema(widget_key="cl_end", data_key="end", label="Travel End Date", placeholder="e.g. 2026-05-14", section="trip"),
91
+ FieldSchema(widget_key="cl_duration", data_key="duration", label="Duration", placeholder="e.g. 14 days", section="trip"),
92
  # ── Applicant section (nested under personal_details) ──
93
+ FieldSchema(widget_key="cl_pd_name", data_key="name", label="Full Name", placeholder="e.g. John Doe", section="applicant", nested_under="personal_details"),
94
+ FieldSchema(widget_key="cl_pd_dob", data_key="dob", label="Date of Birth", placeholder="e.g. 1st Jan 1990", section="applicant", nested_under="personal_details"),
95
+ FieldSchema(widget_key="cl_pd_nationality", data_key="nationality", label="Nationality", placeholder="e.g. Indonesian", section="applicant", nested_under="personal_details"),
96
+ FieldSchema(widget_key="cl_pd_occupation", data_key="occupation", label="Occupation", placeholder="e.g. Engineer", section="applicant", nested_under="personal_details"),
97
+ FieldSchema(widget_key="cl_pd_passport_number", data_key="passport_number", label="Passport Number", placeholder="e.g. A1234567", section="applicant", nested_under="personal_details"),
98
  # ── Contact section ────────────────────────────
99
  FieldSchema(widget_key="cl_trip_highlight", data_key="trip_highlight", label="Trip Highlight", placeholder="Key highlights of your trip...", field_type="text_area", section="contact"),
100
  FieldSchema(widget_key="cl_contact_email", data_key="contact_email", label="Contact Email", placeholder="e.g. john@email.com", section="contact"),
 
112
 
113
  class PrefillDocumentRequest(BaseModel):
114
  """
115
+ Sent to /prefill-document (structure mode).
116
+ `structure` describes the fields the LLM should map from DB data.
117
+ Returns a flat dict of { field_key: value_or_null, ..., "_missing_required": [...] }.
118
  """
119
  application_id: int
120
+ structure: List[Dict[str, Any]]
121
 
122
 
123
  class GenerateDocumentRequest(BaseModel):
 
133
 
134
 
135
  class DocChatRequest(BaseModel):
136
+ """Sent to /generate-document/chat (new stateless draft-revision flow)."""
137
  query: str
138
  history: List[Dict[str, str]]
139
+ session_uuid: Optional[str] = None
140
+ current_document_content: str = ""
141
+ structure: Optional[List[Dict[str, Any]]] = None
142
+
143
+
144
+ class GenerateDraftRequest(BaseModel):
145
+ """
146
+ Sent to /generate-document/draft.
147
+ Generates a Markdown draft without creating a Google Doc.
148
+ `doc_type` and `structure` are stored on the frontend for later handover to the core API.
149
+ """
150
+ doc_type: str
151
+ data: Dict[str, Any]
152
+ structure: Optional[List[Dict[str, Any]]] = None # field definitions for core API handover
153
+ session_uuid: Optional[str] = None
154
+
155
+
156
+ class ExportDocumentRequest(BaseModel):
157
+ """Sent to /export-document once the user is satisfied with the draft."""
158
+ document_content: str
159
+ title: Optional[str] = None
160
+ export_format: str = "gdocs"
161
 
162
 
163
  # ── Shared sub-models (used for type hints / validation internally) ──
src/pages/03_Document_Generator.py CHANGED
@@ -11,10 +11,12 @@ from dotenv import load_dotenv
11
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
12
  from src.document_generator_models import (
13
  DocumentType,
 
14
  DOCUMENT_REGISTRY,
15
  PrefillDocumentRequest,
16
- GenerateDocumentRequest,
17
  DocChatRequest,
 
 
18
  )
19
 
20
  load_dotenv()
@@ -22,97 +24,111 @@ load_dotenv()
22
  API_BASE_URL = os.getenv("URL_CE_BOT", "http://localhost:5000")
23
  TODAY = datetime.date.today()
24
 
 
25
  # ─────────────────────────────────────────────────────────
26
- # COVER LETTER (CL) SESSION STATE & HELPERS
 
27
  # ─────────────────────────────────────────────────────────
28
 
29
- # Widget keys derived from the registry β€” no need to update manually when fields change
30
- CL_FORM_KEYS = DOCUMENT_REGISTRY[DocumentType.COVER_LETTER].widget_keys()
 
31
 
32
 
33
- def _init_cl_state():
34
- defaults = {
35
- "cl_data": None, # Current cover letter form data
36
- "cl_chat_messages": [], # Chat history (display)
37
- "cl_api_history": [], # Simplified history (API)
38
- "cl_history_stack": [], # Undo stack
39
- "cl_redo_stack": [], # Redo stack
40
- "cl_session_uuid": str(uuid.uuid4()), # Server-side session ID
41
- "cl_app_id": "", # Last successfully loaded application ID
42
- "cl_doc_url": None, # Generated document URL
43
- }
44
- for key, val in defaults.items():
45
- if key not in st.session_state:
46
- st.session_state[key] = val
 
 
 
 
 
47
 
48
 
49
- _init_cl_state()
50
 
51
 
 
 
 
52
 
53
- def _cl_clear_form_keys():
54
- """Delete widget session-state keys so they re-initialize from `value=` on next render."""
55
- for key in CL_FORM_KEYS:
56
  st.session_state.pop(key, None)
57
 
58
 
59
- def cl_push_history(data):
60
- if data is not None:
61
- st.session_state.cl_history_stack.append({
62
- "data": copy.deepcopy(data),
63
- "chat": copy.deepcopy(st.session_state.cl_chat_messages),
64
- "api": copy.deepcopy(st.session_state.cl_api_history),
65
- })
66
- st.session_state.cl_redo_stack.clear()
67
-
68
-
69
- def cl_undo():
70
- if st.session_state.cl_history_stack:
71
- st.session_state.cl_redo_stack.append({
72
- "data": copy.deepcopy(st.session_state.cl_data),
73
- "chat": copy.deepcopy(st.session_state.cl_chat_messages),
74
- "api": copy.deepcopy(st.session_state.cl_api_history),
75
- })
76
- prev = st.session_state.cl_history_stack.pop()
77
- st.session_state.cl_data = prev["data"]
78
- st.session_state.cl_chat_messages = prev["chat"]
79
- st.session_state.cl_api_history = prev["api"]
80
- _cl_clear_form_keys()
81
-
82
-
83
- def cl_redo():
84
- if st.session_state.cl_redo_stack:
85
- st.session_state.cl_history_stack.append({
86
- "data": copy.deepcopy(st.session_state.cl_data),
87
- "chat": copy.deepcopy(st.session_state.cl_chat_messages),
88
- "api": copy.deepcopy(st.session_state.cl_api_history),
89
- })
90
- nxt = st.session_state.cl_redo_stack.pop()
91
- st.session_state.cl_data = nxt["data"]
92
- st.session_state.cl_chat_messages = nxt["chat"]
93
- st.session_state.cl_api_history = nxt["api"]
94
- _cl_clear_form_keys()
95
-
96
-
97
- def call_doc_chat_api(query, history, application_id, session_uuid, doc_data=None):
98
- """Call /generate-document/chat endpoint."""
 
 
 
 
 
 
 
 
 
99
  try:
100
- app_id_int = None
101
- if application_id:
102
- try:
103
- app_id_int = int(application_id)
104
- except (ValueError, TypeError):
105
- pass
106
- request = DocChatRequest(
107
  query=query,
108
  history=history,
109
  session_uuid=session_uuid,
110
- application_id=app_id_int,
111
- doc_data=doc_data,
112
  )
113
  resp = requests.post(
114
  f"{API_BASE_URL}/generate-document/chat",
115
- json=request.model_dump(exclude_none=True),
116
  timeout=120,
117
  )
118
  resp.raise_for_status()
@@ -121,87 +137,238 @@ def call_doc_chat_api(query, history, application_id, session_uuid, doc_data=Non
121
  return None, str(e)
122
 
123
 
124
- def call_generate_document(app_id, document_name, form_data):
125
- """Call /generate-document endpoint.
126
- `document_name` tells the backend which SQL to generate (via LLM).
127
- `form_data` is forwarded as `doc_data` for the backend to merge on top of DB data.
128
- """
129
  try:
130
- request = GenerateDocumentRequest(
131
- application_id=int(app_id),
132
- document_name=document_name,
133
- doc_data=form_data or None,
 
 
 
 
 
134
  )
135
  resp = requests.post(
136
- f"{API_BASE_URL}/generate-document",
137
- json=request.model_dump(exclude_none=True),
138
  timeout=120,
139
  )
140
  resp.raise_for_status()
141
  return resp.json(), None
142
- except (ValueError, TypeError) as e:
143
- return None, f"Invalid application_id: {e}"
144
  except Exception as e:
145
  return None, str(e)
146
 
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
 
150
  # ─────────────────────────────────────────────────────────
151
- # PAGE CONFIG & TABS
152
  # ─────────────────────────────────────────────────────────
153
 
154
- st.set_page_config(page_title="Document Generator", layout="wide")
155
- st.title("🌍 Document Generator")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
- tab1, = st.tabs([
158
- "πŸ“ Cover Letter",
159
- ])
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
- # ═══════════════════════════════════════════════════════════
163
- # TAB 1: COVER LETTER (undo/redo + chat, data from DB)
164
- # ═══════════════════════════════════════════════════════════
165
 
166
- with tab1:
167
- st.header("πŸ“ Schengen Visa Cover Letter")
168
- st.caption("Load applicant data by Application ID, edit the fields directly, chat to revise, then generate the Google Doc.")
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
- # ── Application ID row ──
171
  load_c1, load_c2, load_c3 = st.columns([2, 1, 3])
172
  with load_c1:
173
  app_id_input = st.text_input(
174
  "Application ID",
175
- value=st.session_state.cl_app_id,
176
  placeholder="Enter Application ID (e.g. 5786)",
177
- key="cl_app_id_input",
178
  label_visibility="collapsed",
179
  )
180
  with load_c2:
181
- load_clicked = st.button("πŸ” Load from DB", use_container_width=True, key="cl_load_btn")
 
 
 
 
182
  with load_c3:
183
- if st.session_state.cl_data is not None and st.session_state.cl_app_id:
184
  info_c, clear_c = st.columns([4, 1])
185
  with info_c:
186
- st.caption(f"βœ… Loaded: Application ID **{st.session_state.cl_app_id}**")
187
  with clear_c:
188
- if st.button("βœ•", key="cl_clear_btn", help="Clear and start over"):
189
- cl_push_history(st.session_state.cl_data)
190
- _cl_clear_form_keys()
191
- st.session_state.cl_data = None
192
- st.session_state.cl_app_id = ""
193
- st.session_state.cl_doc_url = None
194
- st.session_state.cl_chat_messages = []
195
- st.session_state.cl_api_history = []
196
- st.session_state.cl_session_uuid = str(uuid.uuid4())
 
 
 
197
  st.rerun()
198
 
 
199
  if load_clicked:
200
  if app_id_input:
201
  try:
 
 
 
 
 
 
 
 
 
202
  prefill_req = PrefillDocumentRequest(
203
  application_id=int(app_id_input),
204
- document_name=DocumentType.COVER_LETTER,
205
  )
206
  resp = requests.post(
207
  f"{API_BASE_URL}/prefill-document",
@@ -209,15 +376,25 @@ with tab1:
209
  timeout=60,
210
  )
211
  if resp.status_code == 200:
212
- fetched = resp.json()
213
- cl_push_history(st.session_state.cl_data)
214
- _cl_clear_form_keys()
215
- st.session_state.cl_data = fetched
216
- st.session_state.cl_app_id = app_id_input
217
- st.session_state.cl_doc_url = None
218
- st.session_state.cl_chat_messages = []
219
- st.session_state.cl_api_history = []
220
- st.session_state.cl_session_uuid = str(uuid.uuid4())
 
 
 
 
 
 
 
 
 
 
221
  st.success(f"βœ… Loaded data for Application ID: {app_id_input}")
222
  st.rerun()
223
  else:
@@ -235,205 +412,203 @@ with tab1:
235
 
236
  st.divider()
237
 
238
- # ── Chat history display ──
239
- for msg in st.session_state.cl_chat_messages:
240
- with st.chat_message(msg["role"]):
241
- st.markdown(msg["content"])
242
-
243
- if not st.session_state.cl_data and not st.session_state.cl_chat_messages:
244
- st.info("πŸ“Œ Enter an Application ID above to load data from the database, or use the chat below to describe your cover letter needs.")
245
-
246
- # ── Generated document URL ──
247
- if st.session_state.cl_doc_url:
248
- st.success("πŸŽ‰ Document Generated!")
249
- if "docs.google.com" in st.session_state.cl_doc_url:
250
- st.link_button("🌐 Open in Google Docs", st.session_state.cl_doc_url, use_container_width=True)
251
- else:
252
- st.markdown(f"[πŸ“„ View Document]({st.session_state.cl_doc_url})")
253
 
254
- # ── Editable form (shown when data is loaded) ──
255
- if st.session_state.cl_data is not None:
256
- data = st.session_state.cl_data
257
- updated_data = copy.deepcopy(data)
258
- form_changed = False
259
 
260
- st.subheader("✏️ Edit Details")
261
-
262
- # Trip type
263
- trip_type_opts = ["Individual", "Group"]
264
- cur_trip = data.get("trip_type", "Individual")
265
- if cur_trip not in trip_type_opts:
266
- cur_trip = "Individual"
267
- trip_type = st.radio(
268
- "Trip Type", trip_type_opts,
269
- index=trip_type_opts.index(cur_trip),
270
- horizontal=True, key="cl_trip_type",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  )
272
- if trip_type != cur_trip:
273
- updated_data["trip_type"] = trip_type
274
- form_changed = True
275
-
276
- col1, col2 = st.columns(2)
277
-
278
- with col1:
279
- st.subheader("🌍 Trip Details")
280
- for wk, dk, label, ph in [
281
- ("cl_country", "country", "Country of Embassy", "e.g. Germany"),
282
- ("cl_city", "city_of_application", "City of Application", "e.g. Jakarta"),
283
- ("cl_purpose", "purpose", "Purpose of Trip", "e.g. tourism"),
284
- ("cl_main_dest", "destination_country", "Main Destination", "e.g. Germany"),
285
- ("cl_event", "event", "Event", "e.g. personal vacation"),
286
- ("cl_other_dest", "other_dest", "Other Destinations", "e.g. France and Italy"),
287
- ("cl_start", "start_date", "Travel Start Date", "e.g. 2026-05-01"),
288
- ("cl_end", "end_date", "Travel End Date", "e.g. 2026-05-14"),
289
- ("cl_duration", "duration", "Duration", "e.g. 14 days"),
290
- ]:
291
- cur = data.get(dk, "") or ""
292
- new = st.text_input(label, value=cur, placeholder=ph, key=wk)
293
- if new != cur:
294
- updated_data[dk] = new
295
- form_changed = True
296
 
297
- with col2:
298
- st.subheader("πŸ‘€ Applicant Details")
299
- pd_data = data.get("personal_details", {}) or {}
300
- for wk, fk, label, ph in [
301
- ("cl_pd_name", "name", "Full Name", "e.g. John Doe"),
302
- ("cl_pd_dob", "dob", "Date of Birth", "e.g. 1st Jan 1990"),
303
- ("cl_pd_nationality", "nationality", "Nationality", "e.g. Indonesian"),
304
- ("cl_pd_occupation", "occupation", "Occupation", "e.g. Engineer"),
305
- ("cl_pd_passport_number", "passport_number", "Passport Number", "e.g. A1234567"),
306
- ]:
307
- cur = pd_data.get(fk, "") or ""
308
- new = st.text_input(label, value=cur, placeholder=ph, key=wk)
309
- if new != cur:
310
- if updated_data.get("personal_details") is None:
311
- updated_data["personal_details"] = copy.deepcopy(pd_data)
312
- updated_data["personal_details"][fk] = new
313
- form_changed = True
314
-
315
- st.subheader("πŸ“‹ Contact & Financials")
316
- for wk, dk, label, ph, is_area in [
317
- ("cl_trip_highlight", "trip_highlight", "Trip Highlight", "Key highlights of your trip...", True),
318
- ("cl_contact_email", "contact_email", "Contact Email", "e.g. john@email.com", False),
319
- ("cl_contact_phone", "contact_phone", "Contact Phone", "e.g. +62 812 345 6789", False),
320
- ("cl_job_commitment", "job_commitment", "Job Commitment", "e.g. returning to work on...", False),
321
- ("cl_financial_status", "financial_status", "Financial Status", "e.g. sufficient funds", False),
322
- ]:
323
- cur = data.get(dk, "") or ""
324
- new = (st.text_area if is_area else st.text_input)(label, value=cur, placeholder=ph, key=wk)
325
- if new != cur:
326
- updated_data[dk] = new
327
- form_changed = True
328
-
329
- # Group members table
330
- if trip_type == "Group":
331
- st.subheader("πŸ‘₯ Group Members")
332
- gm_list = data.get("group_members", []) or []
333
- gm_df = pd.DataFrame(
334
- gm_list or [{"relationship": "", "name": "", "dob": "", "occupation": "", "nationality": "", "passport_number": ""}],
335
- columns=["relationship", "name", "dob", "occupation", "nationality", "passport_number"],
336
  )
337
- edited_gm = st.data_editor(gm_df, num_rows="dynamic", use_container_width=True, key="cl_group_members_editor")
338
- new_gm = edited_gm.to_dict("records")
339
- if new_gm != gm_list:
340
- updated_data["group_members"] = new_gm
341
- form_changed = True
342
-
343
- # Apply inline form changes
344
- if form_changed:
345
- cl_push_history(st.session_state.cl_data)
346
- st.session_state.cl_data = updated_data
347
- data = updated_data
 
 
 
 
348
 
349
- # ── Undo / Redo / Generate Toolbar ──
 
350
  st.divider()
351
- tb = st.columns([1, 1, 2, 4, 2])
352
- with tb[0]:
353
- st.button(
354
- "↩️ Undo", on_click=cl_undo,
355
- disabled=not st.session_state.cl_history_stack,
356
- use_container_width=True, key="cl_undo_btn",
357
- )
358
- with tb[1]:
359
- st.button(
360
- "β†ͺ️ Redo", on_click=cl_redo,
361
- disabled=not st.session_state.cl_redo_stack,
362
- use_container_width=True, key="cl_redo_btn",
 
 
 
 
 
 
363
  )
364
- with tb[2]:
365
- h = len(st.session_state.cl_history_stack)
366
- st.caption(f"πŸ“œ {h} version{'s' if h != 1 else ''}")
367
- with tb[4]:
368
- generate_clicked = st.button(
369
- "🌐 Generate Document", type="primary",
370
- use_container_width=True, key="cl_gen_btn",
 
 
 
 
371
  )
 
 
 
 
 
 
 
372
 
373
- if generate_clicked:
374
- if not st.session_state.cl_app_id:
375
- st.error("⚠️ Please load an Application ID first to generate the document.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  else:
377
- with st.spinner("Generating Google Doc... This may take a moment."):
378
- result, err = call_generate_document(
379
- st.session_state.cl_app_id,
380
- DocumentType.COVER_LETTER,
381
- st.session_state.cl_data,
382
- )
383
- if err:
384
- st.error(f"❌ Error: {err}")
385
- elif result and result.get("url"):
386
- st.session_state.cl_doc_url = result["url"]
387
- st.rerun()
388
- else:
389
- st.error("❌ Failed to generate document (no URL returned).")
390
 
391
- # ── Chat input (always visible) ──
392
- st.divider()
393
- placeholder_text = (
394
- "Chat to revise: e.g. 'change destination to France' or 'update passport number to A9876543'"
395
- if st.session_state.cl_data is not None
396
- else "Describe your cover letter needs, or mention an Application ID to load data..."
397
- )
398
- chat_c1, chat_c2 = st.columns([5, 1])
399
- with chat_c1:
400
- chat_text = st.text_input(
401
- "Chat", placeholder=placeholder_text,
402
- key="cl_chat_input", label_visibility="collapsed",
403
  )
404
- with chat_c2:
405
- send_btn = st.button("Send ➀", type="primary", use_container_width=True, key="cl_send_btn")
406
-
407
- if send_btn and chat_text:
408
- st.session_state.cl_chat_messages.append({"role": "user", "content": chat_text})
409
- st.session_state.cl_api_history.append({"role": "user", "content": chat_text})
410
-
411
- active_app_id = st.session_state.cl_app_id or app_id_input or None
412
-
413
- with st.spinner("πŸ€– Processing your request..."):
414
- result, err = call_doc_chat_api(
415
- chat_text,
416
- st.session_state.cl_api_history,
417
- active_app_id,
418
- st.session_state.cl_session_uuid,
419
- doc_data=st.session_state.cl_data,
420
  )
421
 
422
- if err:
423
- st.session_state.cl_chat_messages.append({"role": "assistant", "content": f"❌ Error: {err}"})
424
- elif result:
425
- answer = result.get("answer", "No response received.")
426
- st.session_state.cl_chat_messages.append({"role": "assistant", "content": answer})
427
- st.session_state.cl_api_history.append({"role": "assistant", "content": answer})
428
- if result.get("doc_url"):
429
- st.session_state.cl_doc_url = result["doc_url"]
430
- if result.get("doc_data"):
431
- cl_push_history(st.session_state.cl_data)
432
- _cl_clear_form_keys()
433
- st.session_state.cl_data = result["doc_data"]
434
-
435
- st.session_state.pop("cl_chat_input", None)
436
- st.rerun()
437
-
438
- elif send_btn:
439
- st.warning("Please type a message first.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
12
  from src.document_generator_models import (
13
  DocumentType,
14
+ DocumentSchema,
15
  DOCUMENT_REGISTRY,
16
  PrefillDocumentRequest,
 
17
  DocChatRequest,
18
+ GenerateDraftRequest,
19
+ ExportDocumentRequest,
20
  )
21
 
22
  load_dotenv()
 
24
  API_BASE_URL = os.getenv("URL_CE_BOT", "http://localhost:5000")
25
  TODAY = datetime.date.today()
26
 
27
+
28
  # ─────────────────────────────────────────────────────────
29
+ # GENERIC SESSION STATE
30
+ # Keys: doc_{doc_type}_{suffix} (one set per registered doc type)
31
  # ─────────────────────────────────────────────────────────
32
 
33
+ def _sk(doc_type: str, suffix: str) -> str:
34
+ """Compose a session-state key from doc type + suffix."""
35
+ return f"doc_{doc_type}_{suffix}"
36
 
37
 
38
+ def _init_all_doc_states():
39
+ """Initialize session-state slots for every registered document type."""
40
+ for doc_type in DOCUMENT_REGISTRY:
41
+ defaults = {
42
+ _sk(doc_type, "data"): None,
43
+ _sk(doc_type, "chat_messages"): [],
44
+ _sk(doc_type, "api_history"): [],
45
+ _sk(doc_type, "history_stack"): [],
46
+ _sk(doc_type, "redo_stack"): [],
47
+ _sk(doc_type, "session_uuid"): str(uuid.uuid4()),
48
+ _sk(doc_type, "app_id"): "",
49
+ _sk(doc_type, "draft_content"): "",
50
+ _sk(doc_type, "draft_generated"): False,
51
+ _sk(doc_type, "doc_structure"): None,
52
+ _sk(doc_type, "doc_url"): None,
53
+ }
54
+ for key, val in defaults.items():
55
+ if key not in st.session_state:
56
+ st.session_state[key] = val
57
 
58
 
59
+ _init_all_doc_states()
60
 
61
 
62
+ # ─────────────────────────────────────────────────────────
63
+ # UNDO / REDO
64
+ # ─────────────────────────────────────────────────────────
65
 
66
+ def _clear_widget_keys(doc_type: str):
67
+ """Pop all widget keys for a doc type so widgets reset from value= on next render."""
68
+ for key in DOCUMENT_REGISTRY[doc_type].widget_keys():
69
  st.session_state.pop(key, None)
70
 
71
 
72
+ def push_history(doc_type: str, data):
73
+ if data is None:
74
+ return
75
+ st.session_state[_sk(doc_type, "history_stack")].append({
76
+ "data": copy.deepcopy(data),
77
+ "chat": copy.deepcopy(st.session_state[_sk(doc_type, "chat_messages")]),
78
+ "api": copy.deepcopy(st.session_state[_sk(doc_type, "api_history")]),
79
+ })
80
+ st.session_state[_sk(doc_type, "redo_stack")].clear()
81
+
82
+
83
+ def do_undo(doc_type: str):
84
+ history = st.session_state[_sk(doc_type, "history_stack")]
85
+ if not history:
86
+ return
87
+ st.session_state[_sk(doc_type, "redo_stack")].append({
88
+ "data": copy.deepcopy(st.session_state[_sk(doc_type, "data")]),
89
+ "chat": copy.deepcopy(st.session_state[_sk(doc_type, "chat_messages")]),
90
+ "api": copy.deepcopy(st.session_state[_sk(doc_type, "api_history")]),
91
+ })
92
+ prev = history.pop()
93
+ st.session_state[_sk(doc_type, "data")] = prev["data"]
94
+ st.session_state[_sk(doc_type, "chat_messages")] = prev["chat"]
95
+ st.session_state[_sk(doc_type, "api_history")] = prev["api"]
96
+ _clear_widget_keys(doc_type)
97
+
98
+
99
+ def do_redo(doc_type: str):
100
+ redo = st.session_state[_sk(doc_type, "redo_stack")]
101
+ if not redo:
102
+ return
103
+ st.session_state[_sk(doc_type, "history_stack")].append({
104
+ "data": copy.deepcopy(st.session_state[_sk(doc_type, "data")]),
105
+ "chat": copy.deepcopy(st.session_state[_sk(doc_type, "chat_messages")]),
106
+ "api": copy.deepcopy(st.session_state[_sk(doc_type, "api_history")]),
107
+ })
108
+ nxt = redo.pop()
109
+ st.session_state[_sk(doc_type, "data")] = nxt["data"]
110
+ st.session_state[_sk(doc_type, "chat_messages")] = nxt["chat"]
111
+ st.session_state[_sk(doc_type, "api_history")] = nxt["api"]
112
+ _clear_widget_keys(doc_type)
113
+
114
+
115
+ # ─────────────────────────────────────────────────────────
116
+ # API HELPERS (doc-type-agnostic)
117
+ # ─────────────────────────────────────────────────────────
118
+
119
+ def call_doc_chat_api(query, history, session_uuid, current_document_content="", structure=None):
120
+ """POST /generate-document/chat β€” stateless revision."""
121
  try:
122
+ req = DocChatRequest(
 
 
 
 
 
 
123
  query=query,
124
  history=history,
125
  session_uuid=session_uuid,
126
+ current_document_content=current_document_content,
127
+ structure=structure,
128
  )
129
  resp = requests.post(
130
  f"{API_BASE_URL}/generate-document/chat",
131
+ json=req.model_dump(exclude_none=True),
132
  timeout=120,
133
  )
134
  resp.raise_for_status()
 
137
  return None, str(e)
138
 
139
 
140
+ def call_generate_draft(schema: DocumentSchema, form_data, session_uuid=None):
141
+ """POST /generate-document/draft β€” returns Markdown, no Google Doc created."""
 
 
 
142
  try:
143
+ structure = [
144
+ {"key": f.data_key, "label": f.label, "required": False}
145
+ for f in schema.fields
146
+ ]
147
+ req = GenerateDraftRequest(
148
+ doc_type=str(schema.document_type),
149
+ data=form_data or {},
150
+ structure=structure,
151
+ session_uuid=session_uuid,
152
  )
153
  resp = requests.post(
154
+ f"{API_BASE_URL}/generate-document/draft",
155
+ json=req.model_dump(exclude_none=True),
156
  timeout=120,
157
  )
158
  resp.raise_for_status()
159
  return resp.json(), None
 
 
160
  except Exception as e:
161
  return None, str(e)
162
 
163
 
164
+ def call_export_document(document_content, title=None):
165
+ """POST /export-document β€” converts Markdown to Google Doc."""
166
+ try:
167
+ req = ExportDocumentRequest(document_content=document_content, title=title)
168
+ resp = requests.post(
169
+ f"{API_BASE_URL}/export-document",
170
+ json=req.model_dump(exclude_none=True),
171
+ timeout=120,
172
+ )
173
+ resp.raise_for_status()
174
+ return resp.json(), None
175
+ except Exception as e:
176
+ return None, str(e)
177
 
178
 
179
  # ─────────────────────────────────────────────────────────
180
+ # FORM RENDERING (driven by DocumentSchema metadata)
181
  # ─────────────────────────────────────────────────────────
182
 
183
+ def _get_field_value(data: dict, field) -> str:
184
+ """Read a field's current value, respecting nested_under."""
185
+ if field.nested_under:
186
+ return (data.get(field.nested_under) or {}).get(field.data_key, "") or ""
187
+ return data.get(field.data_key, "") or ""
188
+
189
+
190
+ def _apply_field_change(updated_data: dict, original_data: dict, field, new_val: str):
191
+ """Write a changed value into updated_data, respecting nested_under."""
192
+ if field.nested_under:
193
+ if updated_data.get(field.nested_under) is None:
194
+ updated_data[field.nested_under] = copy.deepcopy(
195
+ original_data.get(field.nested_under) or {}
196
+ )
197
+ updated_data[field.nested_under][field.data_key] = new_val
198
+ else:
199
+ updated_data[field.data_key] = new_val
200
+
201
+
202
+ def _render_section(schema: DocumentSchema, section_key: str, data: dict, updated_data: dict) -> bool:
203
+ """
204
+ Render all fields for one section.
205
+ Returns True if any field value changed.
206
+ """
207
+ st.subheader(schema.sections[section_key])
208
+ changed = False
209
+ for field in schema.fields_in_section(section_key):
210
+ cur = _get_field_value(data, field)
211
+ widget = st.text_area if field.field_type == "text_area" else st.text_input
212
+ new = widget(field.label, value=cur, placeholder=field.placeholder, key=field.widget_key)
213
+ if new != cur:
214
+ _apply_field_change(updated_data, data, field, new)
215
+ changed = True
216
+ return changed
217
+
218
+
219
+ def render_doc_form(schema: DocumentSchema):
220
+ """
221
+ Render the editable form driven entirely by DocumentSchema.
222
 
223
+ Layout rules:
224
+ - 1 section β†’ full width
225
+ - 2+ sections β†’ first N//2 sections in left column, remainder in right column
226
 
227
+ Special elements:
228
+ - has_trip_type=True β†’ radio above columns
229
+ - has_group_members=True β†’ data editor below columns (shown when trip_type=="Group"
230
+ or when the schema has no trip_type toggle)
231
+ """
232
+ doc_type = str(schema.document_type)
233
+ data = st.session_state[_sk(doc_type, "data")]
234
+ updated_data = copy.deepcopy(data)
235
+ form_changed = False
236
+
237
+ st.subheader("✏️ Edit Details")
238
+
239
+ # ── Trip type radio ──────────────────────────────────
240
+ trip_type = None
241
+ if schema.has_trip_type:
242
+ opts = ["Individual", "Group"]
243
+ cur = data.get("trip_type", "Individual")
244
+ if cur not in opts:
245
+ cur = "Individual"
246
+ trip_type = st.radio(
247
+ "Trip Type", opts,
248
+ index=opts.index(cur),
249
+ horizontal=True,
250
+ key=f"{doc_type}_trip_type",
251
+ )
252
+ if trip_type != cur:
253
+ updated_data["trip_type"] = trip_type
254
+ form_changed = True
255
+
256
+ # ── Section columns ──────────────────────────────────
257
+ section_keys = list(schema.sections.keys())
258
+ if len(section_keys) == 1:
259
+ changed = _render_section(schema, section_keys[0], data, updated_data)
260
+ form_changed = form_changed or changed
261
+ else:
262
+ mid = len(section_keys) // 2 # e.g. 3 sections β†’ mid=1 (left:1, right:2)
263
+ left_sections = section_keys[:mid]
264
+ right_sections = section_keys[mid:]
265
+ col1, col2 = st.columns(2)
266
+ with col1:
267
+ for sk in left_sections:
268
+ changed = _render_section(schema, sk, data, updated_data)
269
+ form_changed = form_changed or changed
270
+ with col2:
271
+ for sk in right_sections:
272
+ changed = _render_section(schema, sk, data, updated_data)
273
+ form_changed = form_changed or changed
274
+
275
+ # ── Group members data editor ────────────────────────
276
+ if schema.has_group_members:
277
+ show_group = (trip_type == "Group") if schema.has_trip_type else True
278
+ if show_group:
279
+ st.subheader("πŸ‘₯ Group Members")
280
+ gm_list = data.get("group_members", []) or []
281
+ gm_cols = ["relationship", "name", "dob", "occupation", "nationality", "passport_number"]
282
+ gm_df = pd.DataFrame(
283
+ gm_list or [dict.fromkeys(gm_cols, "")],
284
+ columns=gm_cols,
285
+ )
286
+ edited_gm = st.data_editor(
287
+ gm_df,
288
+ num_rows="dynamic",
289
+ use_container_width=True,
290
+ key=f"{doc_type}_group_members_editor",
291
+ )
292
+ new_gm = edited_gm.to_dict("records")
293
+ if new_gm != gm_list:
294
+ updated_data["group_members"] = new_gm
295
+ form_changed = True
296
+
297
+ # ── Persist changes ──────────────────────────────────
298
+ if form_changed:
299
+ push_history(doc_type, data)
300
+ st.session_state[_sk(doc_type, "data")] = updated_data
301
 
 
 
 
302
 
303
+ # ─────────────────────────────────────────────────────────
304
+ # FULL TAB RENDERER
305
+ # ─────────────────────────────────────────────────────────
306
+
307
+ def render_document_tab(schema: DocumentSchema):
308
+ """Render the complete UI for one document type inside its tab."""
309
+ doc_type = str(schema.document_type)
310
+
311
+ def sk(suffix: str) -> str:
312
+ return _sk(doc_type, suffix)
313
+
314
+ st.header(f"{schema.icon} {schema.title}")
315
+ st.caption(
316
+ "Load applicant data by Application ID, edit the fields directly, "
317
+ "chat to revise, then export the Google Doc."
318
+ )
319
 
320
+ # ── Application ID row ──────────────────────────────
321
  load_c1, load_c2, load_c3 = st.columns([2, 1, 3])
322
  with load_c1:
323
  app_id_input = st.text_input(
324
  "Application ID",
325
+ value=st.session_state[sk("app_id")],
326
  placeholder="Enter Application ID (e.g. 5786)",
327
+ key=f"{doc_type}_app_id_input",
328
  label_visibility="collapsed",
329
  )
330
  with load_c2:
331
+ load_clicked = st.button(
332
+ "πŸ” Load from DB",
333
+ use_container_width=True,
334
+ key=f"{doc_type}_load_btn",
335
+ )
336
  with load_c3:
337
+ if st.session_state[sk("data")] is not None and st.session_state[sk("app_id")]:
338
  info_c, clear_c = st.columns([4, 1])
339
  with info_c:
340
+ st.caption(f"βœ… Loaded: Application ID **{st.session_state[sk('app_id')]}**")
341
  with clear_c:
342
+ if st.button("βœ•", key=f"{doc_type}_clear_btn", help="Clear and start over"):
343
+ push_history(doc_type, st.session_state[sk("data")])
344
+ _clear_widget_keys(doc_type)
345
+ st.session_state[sk("data")] = None
346
+ st.session_state[sk("app_id")] = ""
347
+ st.session_state[sk("doc_url")] = None
348
+ st.session_state[sk("draft_content")] = ""
349
+ st.session_state[sk("draft_generated")] = False
350
+ st.session_state[sk("doc_structure")] = None
351
+ st.session_state[sk("chat_messages")] = []
352
+ st.session_state[sk("api_history")] = []
353
+ st.session_state[sk("session_uuid")] = str(uuid.uuid4())
354
  st.rerun()
355
 
356
+ # ── Load from DB ────────────────────────────────────
357
  if load_clicked:
358
  if app_id_input:
359
  try:
360
+ structure = [
361
+ {
362
+ "key": f.data_key,
363
+ "label": f.label,
364
+ "description": f.placeholder,
365
+ "required": False,
366
+ }
367
+ for f in schema.fields
368
+ ]
369
  prefill_req = PrefillDocumentRequest(
370
  application_id=int(app_id_input),
371
+ structure=structure,
372
  )
373
  resp = requests.post(
374
  f"{API_BASE_URL}/prefill-document",
 
376
  timeout=60,
377
  )
378
  if resp.status_code == 200:
379
+ flat = resp.json()
380
+ # Drop metadata keys (_missing_required, etc.) and reconstruct
381
+ # nested fields (e.g. personal_details.name) from the flat response.
382
+ fetched = {k: v for k, v in flat.items() if not k.startswith("_")}
383
+ for field in schema.fields:
384
+ if field.nested_under and field.data_key in fetched:
385
+ fetched.setdefault(field.nested_under, {})
386
+ fetched[field.nested_under][field.data_key] = fetched.pop(field.data_key)
387
+ push_history(doc_type, st.session_state[sk("data")])
388
+ _clear_widget_keys(doc_type)
389
+ st.session_state[sk("data")] = fetched
390
+ st.session_state[sk("app_id")] = app_id_input
391
+ st.session_state[sk("doc_url")] = None
392
+ st.session_state[sk("draft_content")] = ""
393
+ st.session_state[sk("draft_generated")] = False
394
+ st.session_state[sk("doc_structure")] = None
395
+ st.session_state[sk("chat_messages")] = []
396
+ st.session_state[sk("api_history")] = []
397
+ st.session_state[sk("session_uuid")] = str(uuid.uuid4())
398
  st.success(f"βœ… Loaded data for Application ID: {app_id_input}")
399
  st.rerun()
400
  else:
 
412
 
413
  st.divider()
414
 
415
+ if not st.session_state[sk("data")]:
416
+ st.info("πŸ“Œ Enter an Application ID above to load data from the database.")
417
+ return
 
 
 
 
 
 
 
 
 
 
 
 
418
 
419
+ # ── Editable form ────────────────────────────────────
420
+ render_doc_form(schema)
 
 
 
421
 
422
+ # ── Toolbar: Undo / Redo / Generate Draft ───────────
423
+ st.divider()
424
+ tb = st.columns([1, 1, 2, 4, 2])
425
+ with tb[0]:
426
+ st.button(
427
+ "↩️ Undo",
428
+ on_click=do_undo,
429
+ args=(doc_type,),
430
+ disabled=not st.session_state[sk("history_stack")],
431
+ use_container_width=True,
432
+ key=f"{doc_type}_undo_btn",
433
+ )
434
+ with tb[1]:
435
+ st.button(
436
+ "β†ͺ️ Redo",
437
+ on_click=do_redo,
438
+ args=(doc_type,),
439
+ disabled=not st.session_state[sk("redo_stack")],
440
+ use_container_width=True,
441
+ key=f"{doc_type}_redo_btn",
442
+ )
443
+ with tb[2]:
444
+ h = len(st.session_state[sk("history_stack")])
445
+ st.caption(f"πŸ“œ {h} version{'s' if h != 1 else ''}")
446
+ with tb[4]:
447
+ draft_clicked = st.button(
448
+ "πŸ“ Generate Draft",
449
+ type="primary",
450
+ use_container_width=True,
451
+ key=f"{doc_type}_gen_btn",
452
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
 
454
+ if draft_clicked:
455
+ with st.spinner("Generating document draft..."):
456
+ result, err = call_generate_draft(
457
+ schema,
458
+ st.session_state[sk("data")],
459
+ session_uuid=st.session_state[sk("session_uuid")],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  )
461
+ if err:
462
+ st.error(f"❌ Error generating draft: {err}")
463
+ elif result and result.get("document_content"):
464
+ st.session_state[sk("draft_content")] = result["document_content"]
465
+ st.session_state[sk("draft_generated")] = True
466
+ # Store field structure for future core API handover
467
+ st.session_state[sk("doc_structure")] = [
468
+ {"key": f.data_key, "label": f.label, "required": False}
469
+ for f in schema.fields
470
+ ]
471
+ st.session_state[sk("chat_messages")] = []
472
+ st.session_state[sk("api_history")] = []
473
+ st.rerun()
474
+ else:
475
+ st.error("❌ Failed to generate draft.")
476
 
477
+ # ── Draft preview + export ───────────────────────────
478
+ if st.session_state[sk("draft_generated")]:
479
  st.divider()
480
+ st.subheader("πŸ“„ Document Draft")
481
+ st.caption(
482
+ "Data values are **highlighted in bold**. "
483
+ "Use the chat below to revise, or expand the raw editor to edit directly."
484
+ )
485
+
486
+ # Rendered markdown preview (bold highlights are visible here)
487
+ with st.container(border=True):
488
+ st.markdown(st.session_state[sk("draft_content")])
489
+
490
+ # Collapsible raw Markdown editor for direct edits
491
+ with st.expander("✏️ Edit Raw Markdown"):
492
+ edited_raw = st.text_area(
493
+ "Raw editor",
494
+ value=st.session_state[sk("draft_content")],
495
+ height=420,
496
+ key=f"{doc_type}_draft_editor",
497
+ label_visibility="collapsed",
498
  )
499
+ if edited_raw != st.session_state[sk("draft_content")]:
500
+ st.session_state[sk("draft_content")] = edited_raw
501
+ st.rerun()
502
+
503
+ export_col, link_col = st.columns([2, 3])
504
+ with export_col:
505
+ export_clicked = st.button(
506
+ "🌐 Export to Google Docs",
507
+ type="primary",
508
+ use_container_width=True,
509
+ key=f"{doc_type}_export_btn",
510
  )
511
+ with link_col:
512
+ if st.session_state[sk("doc_url")]:
513
+ st.link_button(
514
+ "πŸ“‚ Open Exported Doc",
515
+ st.session_state[sk("doc_url")],
516
+ use_container_width=True,
517
+ )
518
 
519
+ if export_clicked:
520
+ data = st.session_state[sk("data")] or {}
521
+ applicant_name = (data.get("personal_details") or {}).get("name") or ""
522
+ doc_label = schema.title
523
+ export_title = (
524
+ f"{doc_label} – {applicant_name}".strip(" –")
525
+ if applicant_name
526
+ else doc_label
527
+ )
528
+ with st.spinner("Exporting to Google Docs..."):
529
+ result, err = call_export_document(
530
+ st.session_state[sk("draft_content")],
531
+ title=export_title,
532
+ )
533
+ if err:
534
+ st.error(f"❌ Export failed: {err}")
535
+ elif result and result.get("url"):
536
+ st.session_state[sk("doc_url")] = result["url"]
537
+ st.rerun()
538
  else:
539
+ st.error("❌ Export failed (no URL returned).")
 
 
 
 
 
 
 
 
 
 
 
 
540
 
541
+ # ── Chat ────────────────────────────────────────
542
+ st.divider()
543
+ st.caption(
544
+ "πŸ’¬ Chat to revise the draft β€” "
545
+ "e.g. *'make the second paragraph more formal'* or *'update the passport number to A9876543'*"
 
 
 
 
 
 
 
546
  )
547
+ chat_c1, chat_c2 = st.columns([5, 1])
548
+ with chat_c1:
549
+ chat_text = st.text_input(
550
+ "Chat",
551
+ placeholder="Describe your revision...",
552
+ key=f"{doc_type}_chat_input",
553
+ label_visibility="collapsed",
554
+ )
555
+ with chat_c2:
556
+ send_btn = st.button(
557
+ "Send ➀",
558
+ type="primary",
559
+ use_container_width=True,
560
+ key=f"{doc_type}_send_btn",
 
 
561
  )
562
 
563
+ if send_btn and chat_text:
564
+ st.session_state[sk("chat_messages")].append({"role": "user", "content": chat_text})
565
+ st.session_state[sk("api_history")].append({"role": "user", "content": chat_text})
566
+
567
+ with st.spinner("πŸ€– Revising document..."):
568
+ result, err = call_doc_chat_api(
569
+ chat_text,
570
+ st.session_state[sk("api_history")],
571
+ st.session_state[sk("session_uuid")],
572
+ current_document_content=st.session_state[sk("draft_content")],
573
+ structure=st.session_state[sk("doc_structure")],
574
+ )
575
+
576
+ if err:
577
+ st.session_state[sk("chat_messages")].append(
578
+ {"role": "assistant", "content": f"❌ Error: {err}"}
579
+ )
580
+ elif result:
581
+ answer = result.get("answer", "Document updated.")
582
+ st.session_state[sk("chat_messages")].append({"role": "assistant", "content": answer})
583
+ st.session_state[sk("api_history")].append({"role": "assistant", "content": answer})
584
+ if result.get("updated_document_content"):
585
+ st.session_state[sk("draft_content")] = result["updated_document_content"]
586
+ if result.get("structure") is not None:
587
+ st.session_state[sk("doc_structure")] = result["structure"]
588
+
589
+ st.session_state.pop(f"{doc_type}_chat_input", None)
590
+ st.rerun()
591
+
592
+ elif send_btn:
593
+ st.warning("Please type a message first.")
594
+
595
+ # ── Chat history ─────────────────────────────────
596
+ for msg in st.session_state[sk("chat_messages")]:
597
+ with st.chat_message(msg["role"]):
598
+ st.markdown(msg["content"])
599
+
600
+
601
+ # ─────────────────────────────────────────────────────────
602
+ # PAGE ENTRY POINT
603
+ # ─────────────────────────────────────────────────────────
604
+
605
+ st.set_page_config(page_title="Document Generator", layout="wide")
606
+ st.title("🌍 Document Generator")
607
+
608
+ # Tabs are generated from DOCUMENT_REGISTRY β€” add a new DocumentSchema to get a new tab
609
+ tab_labels = [f"{schema.icon} {schema.title}" for schema in DOCUMENT_REGISTRY.values()]
610
+ tabs = st.tabs(tab_labels)
611
+
612
+ for tab, (doc_type, schema) in zip(tabs, DOCUMENT_REGISTRY.items()):
613
+ with tab:
614
+ render_document_tab(schema)