Rishabh2095 commited on
Commit
15c0cf5
·
0 Parent(s):

New Repo for Agent Workflow Frontend

Browse files
.gitignore ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -------------------------------------------------
2
+ # Environment files
3
+ # -------------------------------------------------
4
+ .env
5
+ .env.*
6
+ *.env
7
+
8
+ # -------------------------------------------------
9
+ # Python cache / compiled files
10
+ # -------------------------------------------------
11
+ __pycache__/
12
+ *.py[cod]
13
+ *.pyo
14
+ *.pyd
15
+ *.egg-info/
16
+ *.egg
17
+ *.dist-info/
18
+
19
+ # -------------------------------------------------
20
+ # Log files and log folder
21
+ # -------------------------------------------------
22
+ logs/
23
+ *.log
24
+
25
+ # -------------------------------------------------
26
+ # Cursor‑specific files (project‑specific metadata)
27
+ # -------------------------------------------------
28
+ .cursor/
29
+ *.cursor
30
+
31
+ # -------------------------------------------------
32
+ # Virtual environment directories
33
+ # -------------------------------------------------
34
+ # If you use a venv folder named “venv” (common default)
35
+ venv/
36
+ # If you use a .venv folder (also common)
37
+ .venv/
38
+ # If you use a virtual environment named “env”
39
+ env/
job_writer_ui/api/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API module for LangGraph workflow integration.
3
+
4
+ This module provides functions for interacting with the LangGraph API
5
+ to manage workflow execution, interrupts, and resumption.
6
+ """
7
+
8
+ from api.workflow import resume_with_feedback, start_generation
9
+
10
+ __all__ = ["resume_with_feedback", "start_generation"]
job_writer_ui/api/workflow.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LangGraph API integration module.
3
+
4
+ This module handles:
5
+ - Workflow initialization and execution
6
+ - Interrupt handling and state management
7
+ - Workflow resumption with user feedback
8
+ """
9
+
10
+ from typing import Any, Dict, Optional, Tuple
11
+
12
+ from config import (
13
+ ASSISTANT_ID,
14
+ DEFAULT_RESUME_PATH,
15
+ LANGGRAPH_CLIENT,
16
+ logger,
17
+ log_activity,
18
+ )
19
+
20
+
21
+ class WorkflowError(Exception):
22
+ """Custom exception for workflow-related errors."""
23
+
24
+ pass
25
+
26
+
27
+ def get_current_workflow_node(state_dict: Dict[str, Any]) -> str:
28
+ """
29
+ Extract current node from thread state.
30
+
31
+ Args:
32
+ state_dict: Thread state dictionary
33
+
34
+ Returns:
35
+ Current node name, defaults to 'human_approval' if not found
36
+ """
37
+ next_nodes = state_dict.get("next", [])
38
+ current_node = next_nodes[0] if next_nodes else None
39
+
40
+ if not current_node:
41
+ current_node = "human_approval"
42
+ logger.warning(
43
+ "Could not extract current_node from state, using fallback: %s",
44
+ current_node,
45
+ )
46
+ else:
47
+ log_activity("Extracted current_node", current_node=current_node)
48
+
49
+ return current_node
50
+
51
+
52
+ def start_generation(
53
+ job_url: str,
54
+ content_type: str,
55
+ state: Dict[str, Any],
56
+ resume_url: Optional[str] = None,
57
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
58
+ """
59
+ Start the workflow and handle potential interrupt.
60
+
61
+ Args:
62
+ job_url: Job posting URL
63
+ content_type: Type of content to generate
64
+ state: Current application state
65
+ resume_url: Optional resume file URL (uses default if not provided)
66
+
67
+ Returns:
68
+ Tuple of (result_dict, updated_state).
69
+ Result dict contains either interrupt info or completed result.
70
+
71
+ Raises:
72
+ WorkflowError: If workflow execution fails
73
+ ValueError: If required parameters are missing
74
+ """
75
+ if not job_url or not job_url.strip():
76
+ return {"error": "Please provide a job posting URL."}, state
77
+
78
+ try:
79
+ thread = LANGGRAPH_CLIENT.threads.create()
80
+ thread_id = thread["thread_id"]
81
+ log_activity("Created thread", thread_id=thread_id)
82
+
83
+ resume_path = (
84
+ resume_url.strip()
85
+ if resume_url and resume_url.strip()
86
+ else DEFAULT_RESUME_PATH
87
+ )
88
+
89
+ payload = {
90
+ "resume_path": resume_path,
91
+ "job_description_source": job_url.strip(),
92
+ "content_category": content_type,
93
+ "current_node": "load",
94
+ "messages": [],
95
+ "company_research_data": {},
96
+ }
97
+
98
+ run = LANGGRAPH_CLIENT.runs.create(
99
+ thread_id=thread_id, assistant_id=ASSISTANT_ID, input=payload
100
+ )
101
+ run_id = (
102
+ run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
103
+ )
104
+ log_activity("Created initial run", run_id=run_id, thread_id=thread_id)
105
+
106
+ LANGGRAPH_CLIENT.runs.wait(thread_id=thread_id, assistant_id=ASSISTANT_ID)
107
+
108
+ thread_state = LANGGRAPH_CLIENT.threads.get_state(thread_id=thread_id)
109
+ state_dict = (
110
+ thread_state if isinstance(thread_state, dict) else thread_state.__dict__
111
+ )
112
+
113
+ if state_dict.get("interrupts"):
114
+ interrupt_value = state_dict["interrupts"][0].get("value", {})
115
+ current_node = get_current_workflow_node(state_dict)
116
+
117
+ return {
118
+ "status": "interrupted",
119
+ "thread_id": thread_id,
120
+ "draft": interrupt_value.get("draft", ""),
121
+ "message": interrupt_value.get("message", "Please review the draft"),
122
+ "current_node": current_node,
123
+ }, {"thread_id": thread_id, "run_id": run_id, "current_node": current_node}
124
+
125
+ return {
126
+ "status": "completed",
127
+ "result": state_dict.get("values", {}).get("output_data", state_dict),
128
+ }, {"thread_id": thread_id, "run_id": run_id}
129
+
130
+ except (ValueError, ConnectionError, RuntimeError) as error:
131
+ logger.error("Request failed: %s", error, exc_info=True)
132
+ return {"error": "Request failed", "details": str(error)}, state
133
+
134
+
135
+ def cancel_run(*, thread_id: str, run_id: str, action: str = "cancel") -> bool:
136
+ """Cancel a LangGraph run safely.
137
+
138
+ Returns True if cancellation request was sent successfully.
139
+ """
140
+ try:
141
+ LANGGRAPH_CLIENT.runs.cancel(
142
+ thread_id=thread_id,
143
+ run_id=run_id,
144
+ action=action,
145
+ )
146
+ log_activity("Cancelled run", thread_id=thread_id, run_id=run_id, action=action)
147
+ return True
148
+ except Exception as exc:
149
+ log_activity(
150
+ "Failed to cancel run",
151
+ thread_id=thread_id,
152
+ run_id=run_id,
153
+ action=action,
154
+ error=str(exc),
155
+ )
156
+ logger.warning(
157
+ "Failed to cancel run %s for thread %s: %s",
158
+ run_id,
159
+ thread_id,
160
+ exc,
161
+ exc_info=True,
162
+ )
163
+ return False
164
+
165
+
166
+ def cancel_current_run(state: Dict[str, Any]) -> Dict[str, Any]:
167
+ """Cancel the current run from UI.
168
+
169
+ Args:
170
+ state: Current application state containing thread_id and run_id
171
+
172
+ Returns:
173
+ Status dictionary with cancellation result
174
+ """
175
+ if not state:
176
+ log_activity("Cancel attempted with empty state")
177
+ return {
178
+ "status": "error",
179
+ "message": "No active run to cancel. State is empty.",
180
+ }
181
+
182
+ thread_id = state.get("thread_id")
183
+ run_id = state.get("run_id")
184
+
185
+ log_activity(
186
+ "Cancel run requested",
187
+ thread_id=thread_id,
188
+ run_id=run_id,
189
+ state_keys=list(state.keys()) if state else [],
190
+ )
191
+
192
+ if not thread_id or not run_id:
193
+ return {
194
+ "status": "error",
195
+ "message": f"No active run to cancel. Missing thread_id or run_id. (thread_id={thread_id}, run_id={run_id})",
196
+ }
197
+
198
+ success = cancel_run(thread_id=thread_id, run_id=run_id, action="ui_cancel")
199
+ print("Cancellation Message:", success)
200
+
201
+ if success:
202
+ return {
203
+ "status": "cancelled",
204
+ "message": "Run has been cancelled successfully.",
205
+ "thread_id": thread_id,
206
+ "run_id": run_id,
207
+ }
208
+ else:
209
+ return {
210
+ "status": "error",
211
+ "message": "Failed to cancel the run. Please try again.",
212
+ "thread_id": thread_id,
213
+ "run_id": run_id,
214
+ }
215
+
216
+
217
+ def resume_with_feedback(
218
+ feedback: str, state: Dict[str, Any]
219
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
220
+ """
221
+ Resume workflow after interrupt with user feedback.
222
+
223
+ Empty feedback string means approve as-is.
224
+
225
+ Args:
226
+ feedback: User feedback text (empty string means approve)
227
+ state: Current application state with thread_id
228
+
229
+ Returns:
230
+ Tuple of (result_dict, updated_state).
231
+ Result dict contains completed result or error.
232
+
233
+ Raises:
234
+ WorkflowError: If resume fails
235
+ ValueError: If thread_id is missing from state
236
+ """
237
+ thread_id = state.get("thread_id")
238
+ if not thread_id:
239
+ return {"error": "No active workflow to resume"}, state
240
+
241
+ current_node = state.get("current_node", "human_approval")
242
+ if not current_node:
243
+ current_node = "human_approval"
244
+ logger.warning(
245
+ "No current_node in state for thread %s, using fallback: %s",
246
+ thread_id,
247
+ current_node,
248
+ )
249
+
250
+ log_activity(
251
+ "Resuming thread",
252
+ thread_id=thread_id,
253
+ current_node=current_node,
254
+ feedback=feedback[:50] if feedback else "empty",
255
+ )
256
+
257
+ try:
258
+ LANGGRAPH_CLIENT.threads.update_state(
259
+ thread_id=thread_id,
260
+ values={"feedback": feedback.strip()},
261
+ as_node=current_node,
262
+ )
263
+ log_activity("State updated", thread_id=thread_id, current_node=current_node)
264
+
265
+ run = LANGGRAPH_CLIENT.runs.create(
266
+ thread_id=thread_id, assistant_id=ASSISTANT_ID, input=None
267
+ )
268
+ run_id = (
269
+ run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
270
+ )
271
+ log_activity("Created resume run", run_id=run_id, thread_id=thread_id)
272
+
273
+ LANGGRAPH_CLIENT.runs.wait(thread_id=thread_id, assistant_id=ASSISTANT_ID)
274
+
275
+ final_state = LANGGRAPH_CLIENT.threads.get_state(thread_id=thread_id)
276
+ state_dict = (
277
+ final_state if isinstance(final_state, dict) else final_state.__dict__
278
+ )
279
+
280
+ # Update state with the new run_id from resume
281
+ updated_state = state.copy()
282
+ updated_state["run_id"] = run_id
283
+
284
+ return {
285
+ "status": "completed",
286
+ "result": state_dict.get("values", {}).get("output_data", state_dict),
287
+ }, updated_state
288
+
289
+ except (ValueError, ConnectionError, RuntimeError) as error:
290
+ logger.error("Resume failed: %s", error, exc_info=True)
291
+ return {"error": "Resume failed", "details": str(error)}, state
job_writer_ui/app.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main application entry point for Job Application Writer UI.
3
+
4
+ This module initializes the Gradio interface, creates UI components,
5
+ and wires event handlers. All business logic is delegated to specialized
6
+ modules (api, ui, utils).
7
+ """
8
+
9
+ # Third-party imports
10
+ import gradio as gr
11
+ import os
12
+
13
+ # Local application imports
14
+ from config import APP_PORT, log_activity
15
+ from static.js import CUSTOM_JS
16
+ from api.workflow import cancel_current_run
17
+ from ui.handlers import (
18
+ approve_final_content,
19
+ execute_workflow,
20
+ handle_feedback,
21
+ show_chat_interface,
22
+ )
23
+ from utils.content import download_content_as_file, extract_message_content
24
+ from utils.state import reset_session
25
+ from utils.validation import validate_form_inputs
26
+
27
+ # ============================================================================
28
+ # Gradio UI Definition
29
+ # ============================================================================
30
+
31
+ # Get the absolute path to the CSS file
32
+ _css_path = os.path.join(os.path.dirname(__file__), "static", "custom.css")
33
+
34
+ with gr.Blocks(
35
+ title="Job Application Assistant",
36
+ theme=gr.themes.Soft(),
37
+ css_paths=[_css_path],
38
+ head=f"<script>{CUSTOM_JS}</script>",
39
+ ) as demo:
40
+ state = gr.State(value={})
41
+
42
+ # Phase 1: Form Interface
43
+ with gr.Group(visible=True) as form_phase:
44
+ with gr.Row(elem_classes=["container", "section"]):
45
+ with gr.Column(scale=1, elem_classes=["form-container", "card"]):
46
+ gr.Markdown(
47
+ """
48
+ <div class="page-header">
49
+ <h1 class="page-title">Job Application Writer</h1>
50
+ <p class="helper-text">
51
+ Generate personalized job application content from a job posting URL.
52
+ </p>
53
+ </div>
54
+ """
55
+ )
56
+ job_url = gr.Textbox(
57
+ label="Job Posting URL",
58
+ placeholder="https://example.com/job-posting",
59
+ lines=1,
60
+ )
61
+ email = gr.Textbox(
62
+ label="Email ID",
63
+ placeholder="you@example.com",
64
+ lines=1,
65
+ )
66
+ resume_url = gr.Textbox(
67
+ label="Resume File URL",
68
+ placeholder="https://example.com/resume.pdf",
69
+ lines=1,
70
+ info=(
71
+ "URL where your resume has been uploaded "
72
+ "(optional - uses default if empty)"
73
+ ),
74
+ )
75
+ content_type = gr.Dropdown(
76
+ choices=["cover_letter", "bullets", "linkedin_note"],
77
+ value=None,
78
+ label="Content Type",
79
+ info="Select the type of content to generate",
80
+ )
81
+ form_error = gr.Markdown("", elem_classes=["form-error"])
82
+ generate_btn = gr.Button(
83
+ "Generate Content",
84
+ variant="primary",
85
+ size="lg",
86
+ elem_classes=["btn-primary"],
87
+ interactive=False,
88
+ )
89
+
90
+ # Phase 2: Chat Interface
91
+ with gr.Group(visible=False) as chat_phase:
92
+ with gr.Row(elem_classes=["container", "section"]):
93
+ with gr.Column(elem_classes=["chat-phase-container", "card"]):
94
+ with gr.Row(elem_classes=["chat-header-row"]):
95
+ gr.Markdown(
96
+ "<div class='chat-page-header'><h1 class='page-title'>Job Application Writer</h1></div>",
97
+ elem_classes=["chat-title"],
98
+ )
99
+ new_session_btn = gr.Button(
100
+ "New chat",
101
+ variant="primary",
102
+ size="sm",
103
+ elem_classes=["plus-button"],
104
+ min_width=48,
105
+ )
106
+ chatbot = gr.Chatbot(
107
+ label="Conversation",
108
+ height=500,
109
+ elem_classes=["chat-container"],
110
+ show_label=False,
111
+ )
112
+
113
+ with gr.Group(
114
+ visible=True, elem_classes=["section"]
115
+ ) as feedback_section:
116
+ cancel_run_btn = gr.Button(
117
+ "Cancel Run",
118
+ variant="stop",
119
+ size="sm",
120
+ elem_classes=["btn-secondary"],
121
+ visible=False, # Hidden by default, shown when run is active
122
+ )
123
+ cancel_status_msg = gr.Markdown("", visible=False)
124
+ feedback_input = gr.Textbox(
125
+ label="Your Feedback",
126
+ placeholder=(
127
+ "Provide feedback to improve the content, "
128
+ "or leave empty to approve..."
129
+ ),
130
+ lines=3,
131
+ )
132
+ with gr.Row():
133
+ send_btn = gr.Button(
134
+ "Send Feedback",
135
+ variant="primary",
136
+ elem_classes=["btn-primary"],
137
+ interactive=False,
138
+ )
139
+ approve_btn = gr.Button(
140
+ "Approve Final Version",
141
+ variant="secondary",
142
+ elem_classes=["btn-secondary"],
143
+ interactive=False,
144
+ )
145
+
146
+ with gr.Group(
147
+ visible=False, elem_classes=["section"]
148
+ ) as action_buttons:
149
+ with gr.Row():
150
+ copy_btn = gr.Button(
151
+ "Copy to Clipboard",
152
+ variant="secondary",
153
+ elem_classes=["btn-secondary"],
154
+ )
155
+ download_btn = gr.Button(
156
+ "Download",
157
+ variant="secondary",
158
+ elem_classes=["btn-secondary"],
159
+ )
160
+ copy_textbox = gr.Textbox(
161
+ label="Content (select and copy manually)",
162
+ visible=False,
163
+ interactive=True,
164
+ lines=10,
165
+ )
166
+
167
+ download_file = gr.File(label="Download", visible=False)
168
+
169
+ # Event Handlers
170
+ def _validate_and_toggle(job_url_val, content_type_val, resume_url_val, email_val):
171
+ is_valid, err_md = validate_form_inputs(
172
+ job_url=job_url_val,
173
+ content_type=content_type_val,
174
+ resume_url=resume_url_val,
175
+ email=email_val,
176
+ )
177
+ return gr.update(interactive=is_valid), err_md
178
+
179
+ job_url.change(
180
+ fn=_validate_and_toggle,
181
+ inputs=[job_url, content_type, resume_url, email],
182
+ outputs=[generate_btn, form_error],
183
+ )
184
+ content_type.change(
185
+ fn=_validate_and_toggle,
186
+ inputs=[job_url, content_type, resume_url, email],
187
+ outputs=[generate_btn, form_error],
188
+ )
189
+ resume_url.change(
190
+ fn=_validate_and_toggle,
191
+ inputs=[job_url, content_type, resume_url, email],
192
+ outputs=[generate_btn, form_error],
193
+ )
194
+ email.change(
195
+ fn=_validate_and_toggle,
196
+ inputs=[job_url, content_type, resume_url, email],
197
+ outputs=[generate_btn, form_error],
198
+ )
199
+
200
+ generate_btn.click(
201
+ fn=show_chat_interface,
202
+ inputs=[chatbot, state, content_type],
203
+ outputs=[chatbot, state, form_phase, chat_phase, send_btn, approve_btn],
204
+ ).then(
205
+ fn=execute_workflow,
206
+ inputs=[job_url, content_type, resume_url, chatbot, state],
207
+ outputs=[chatbot, state, send_btn, approve_btn, cancel_run_btn],
208
+ )
209
+
210
+ def handle_cancel_run(current_state):
211
+ """Handle cancel run button click."""
212
+ # Ensure we have a dict and not None
213
+ if not current_state:
214
+ current_state = {}
215
+
216
+ result = cancel_current_run(current_state)
217
+ status = result.get("status", "error")
218
+ message = result.get("message", "")
219
+
220
+ # Update state with result (preserve existing state keys, update with result)
221
+ updated_state = current_state.copy()
222
+ updated_state.update(result)
223
+
224
+ # Hide button on success, show status message
225
+ if status == "cancelled":
226
+ return (
227
+ updated_state, # Updated state
228
+ gr.update(visible=False), # Hide cancel button
229
+ gr.update(visible=True, value=f"✓ {message}"), # Show success message
230
+ )
231
+ else:
232
+ # Show error message but keep button visible
233
+ return (
234
+ current_state, # Keep current state on error
235
+ gr.update(visible=True), # Keep button visible
236
+ gr.update(visible=True, value=f"⚠ {message}"), # Show error message
237
+ )
238
+
239
+ cancel_run_btn.click(
240
+ fn=handle_cancel_run,
241
+ inputs=[state],
242
+ outputs=[state, cancel_run_btn, cancel_status_msg],
243
+ )
244
+
245
+ send_btn.click(
246
+ fn=handle_feedback,
247
+ inputs=[chatbot, feedback_input, state],
248
+ outputs=[chatbot, feedback_input, state, cancel_run_btn],
249
+ )
250
+
251
+ approve_btn.click(
252
+ fn=approve_final_content,
253
+ inputs=[chatbot, state],
254
+ outputs=[chatbot, feedback_section, action_buttons],
255
+ )
256
+
257
+ copy_btn.click(
258
+ fn=extract_message_content,
259
+ inputs=[chatbot],
260
+ outputs=[copy_textbox],
261
+ ).then(
262
+ lambda: gr.update(visible=True),
263
+ outputs=[copy_textbox],
264
+ )
265
+
266
+ download_btn.click(
267
+ fn=download_content_as_file,
268
+ inputs=[chatbot, state],
269
+ outputs=[download_file],
270
+ )
271
+
272
+ new_session_btn.click(
273
+ fn=reset_session,
274
+ inputs=None,
275
+ outputs=[form_phase, chat_phase, chatbot, state],
276
+ )
277
+
278
+ demo.queue(max_size=10, default_concurrency_limit=2)
279
+
280
+
281
+ # Application Entry Point
282
+
283
+ if __name__ == "__main__":
284
+ log_activity("Application started")
285
+ demo.launch(server_name="0.0.0.0", server_port=APP_PORT, theme=gr.themes.Ocean())
job_writer_ui/config.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration module for Job Application Writer UI.
3
+
4
+ This module handles:
5
+ - Logging configuration
6
+ - Environment variable loading
7
+ - Application constants
8
+ - LangGraph client initialization
9
+ """
10
+
11
+ import logging
12
+ import os
13
+ from typing import Optional
14
+
15
+ from dotenv import load_dotenv
16
+ from langgraph_sdk import get_sync_client
17
+
18
+ # Logging configuration
19
+ logging.basicConfig(
20
+ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
21
+ )
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # File handler for logging to file
25
+ log_file_path = os.path.join(
26
+ os.path.dirname(os.path.abspath(__file__)), "..", "logs", "app.log"
27
+ )
28
+ file_handler = logging.FileHandler(log_file_path)
29
+ file_handler.setLevel(logging.INFO)
30
+ file_handler.setFormatter(
31
+ logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
32
+ )
33
+ logger.addHandler(file_handler)
34
+
35
+
36
+ def log_activity(message: str, **kwargs):
37
+ """Log a structured activity message.
38
+
39
+ Args:
40
+ message: Main log message.
41
+ **kwargs: Additional context such as thread_id, run_id, etc.
42
+ """
43
+ if kwargs:
44
+ context = " | ".join(f"{key}={value}" for key, value in kwargs.items())
45
+ logger.info(f"{message} | {context}")
46
+ else:
47
+ logger.info(message)
48
+
49
+
50
+ # Load environment variables
51
+ load_dotenv()
52
+
53
+ # Configuration constants
54
+ ASSISTANT_ID = os.getenv("ASSISTANT_ID", "job_app_graph")
55
+ LANGGRAPH_API_URL = os.getenv(
56
+ "LANGGRAPH_API_URL", "https://rishabh2095-agentworkflowjobapplications.hf.space"
57
+ ).rstrip("/")
58
+ LANGGRAPH_API_KEY: Optional[str] = os.getenv("LANGGRAPH_API_KEY")
59
+ APP_PORT = int(os.getenv("APP_PORT", "7865"))
60
+
61
+ # Default resume path
62
+ DEFAULT_RESUME_PATH = (
63
+ "https://huggingface.co/datasets/Rishabh2095/"
64
+ "resume-file-dataset/resolve/main/resume.pdf"
65
+ )
66
+
67
+ # Initialize LangGraph client
68
+ try:
69
+ LANGGRAPH_CLIENT = get_sync_client(url=LANGGRAPH_API_URL, api_key=LANGGRAPH_API_KEY)
70
+ logger.info(
71
+ "LangGraph client initialized successfully with URL: %s", LANGGRAPH_API_URL
72
+ )
73
+ except (ValueError, ConnectionError) as error:
74
+ logger.error(
75
+ "Failed to initialize LangGraph client: %s. "
76
+ "Check that LANGGRAPH_API_URL (%s) is correct and the server is running.",
77
+ error,
78
+ LANGGRAPH_API_URL,
79
+ )
80
+ raise
job_writer_ui/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ requests>=2.31.0
3
+ python-dotenv>=1.0.0
4
+ langgraph-sdk>=0.0.34
5
+ pydantic>=2.0.0
job_writer_ui/static/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Static assets module for Job Application Writer UI.
3
+
4
+ This package provides JavaScript constants for interactive features.
5
+ CSS is now loaded from static/custom.css file via Gradio's css_paths.
6
+ """
7
+
8
+ from static.js import CUSTOM_JS
9
+
10
+ __all__ = ["CUSTOM_JS"]
job_writer_ui/static/custom.css ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
2
+
3
+ :root {
4
+ --container-max-width: 64rem;
5
+ --page-padding: 1.5rem;
6
+ --card-padding: 2rem;
7
+ --bg-page: #F3F4F6;
8
+ --bg-card: #FFFFFF;
9
+ --text-primary: #0F172A;
10
+ --text-secondary: #475569;
11
+ --helper: #6B7280;
12
+ --brand: #5B5CE2;
13
+ }
14
+
15
+ html,
16
+ body {
17
+ height: 100% !important;
18
+ margin: 0 !important;
19
+ padding: 0 !important;
20
+ background-color: var(--bg-page) !important;
21
+ color: var(--text-primary) !important;
22
+ }
23
+
24
+ .gradio-container {
25
+ min-height: 100vh !important;
26
+ width: 100vw !important;
27
+ background-color: var(--bg-page) !important;
28
+ margin: 0 !important;
29
+ padding: 2rem 0 !important;
30
+ }
31
+
32
+ .container {
33
+ max-width: var(--container-max-width);
34
+ margin: 0 auto;
35
+ padding-inline: var(--page-padding);
36
+ }
37
+
38
+ .page-header {
39
+ padding: 2rem 0 1rem;
40
+ }
41
+
42
+ .chat-page-header {
43
+ width: 100%;
44
+ text-align: center;
45
+ padding: 0 0 0.5rem;
46
+ }
47
+
48
+ .page-title {
49
+ font-size: 2rem;
50
+ font-weight: 700;
51
+ color: var(--text-primary);
52
+ margin: 0;
53
+ }
54
+
55
+ .card {
56
+ background: var(--bg-card);
57
+ border-radius: 0.75rem;
58
+ padding: var(--card-padding);
59
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
60
+ }
61
+
62
+ .section {
63
+ margin-top: 2rem;
64
+ }
65
+
66
+ .section-title {
67
+ font-size: 1.25rem;
68
+ font-weight: 600;
69
+ margin-bottom: 1rem;
70
+ color: var(--text-primary);
71
+ }
72
+
73
+ .helper-text {
74
+ font-size: 0.875rem;
75
+ color: var(--helper);
76
+ }
77
+
78
+ /* Hide Chatbot toolbar widgets (top-right icons) */
79
+ .gradio-chatbot .toolbar,
80
+ .gradio-chatbot button[aria-label*="Share"],
81
+ .gradio-chatbot button[title*="Share"],
82
+ .gradio-chatbot button[aria-label*="Download"],
83
+ .gradio-chatbot button[title*="Download"],
84
+ .gradio-chatbot button[aria-label*="Clear"],
85
+ .gradio-chatbot button[title*="Clear"],
86
+ .gradio-chatbot button[aria-label*="Fullscreen"],
87
+ .gradio-chatbot button[title*="Fullscreen"] {
88
+ display: none !important;
89
+ }
90
+
91
+ .btn-primary button {
92
+ background: var(--brand) !important;
93
+ color: #FFFFFF !important;
94
+ border-color: var(--brand) !important;
95
+ }
96
+
97
+ .btn-secondary button {
98
+ background: transparent !important;
99
+ color: var(--brand) !important;
100
+ border-color: var(--brand) !important;
101
+ }
102
+
103
+ @media (max-width: 768px) {
104
+ .container {
105
+ padding-inline: 1rem;
106
+ }
107
+
108
+ .page-title {
109
+ font-size: 1.75rem;
110
+ }
111
+ }
112
+
113
+ @media (max-width: 640px) {
114
+ #fab {
115
+ bottom: 1.5rem;
116
+ right: 1.5rem;
117
+ }
118
+ }
119
+
120
+ * {
121
+ font-family: 'Inter', sans-serif;
122
+ }
123
+
124
+ .form-container {
125
+ max-width: 800px !important;
126
+ width: 90% !important;
127
+ margin: 3rem auto !important;
128
+ padding: 3rem !important;
129
+ background: #ffffff !important;
130
+ border-radius: 12px !important;
131
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1) !important;
132
+ display: flex !important;
133
+ flex-direction: column !important;
134
+ justify-content: center !important;
135
+ }
136
+
137
+ .chat-container {
138
+ height: 600px;
139
+ }
140
+
141
+ .chat-phase-header {
142
+ display: flex !important;
143
+ justify-content: space-between !important;
144
+ align-items: center !important;
145
+ padding: 1.5rem 0 !important;
146
+ margin-bottom: 1rem !important;
147
+ }
148
+
149
+ .chat-phase-header h3 {
150
+ margin: 0 !important;
151
+ padding: 0 !important;
152
+ font-family: 'Inter', sans-serif !important;
153
+ font-size: 1.5rem !important;
154
+ font-weight: 600 !important;
155
+ }
156
+
157
+ .chat-phase-container {
158
+ display: flex !important;
159
+ flex-direction: column !important;
160
+ justify-content: center !important;
161
+ min-height: 100vh !important;
162
+ padding: 2rem 0 !important;
163
+ }
164
+
165
+ [data-testid="bot"] .markdown,
166
+ [data-testid="user"] .markdown,
167
+ .message-wrap .markdown {
168
+ padding: 1rem !important;
169
+ margin: 0.5rem 0 !important;
170
+ }
171
+
172
+ .gradio-chatbot {
173
+ margin: 1rem 0 !important;
174
+ }
175
+
176
+ .primary-button {
177
+ background-color: #4f46e5;
178
+ color: white;
179
+ border: none;
180
+ border-radius: 8px;
181
+ padding: 0.75rem 1.5rem;
182
+ font-weight: 600;
183
+ cursor: pointer;
184
+ }
185
+
186
+ .primary-button:hover {
187
+ background-color: #4338ca;
188
+ }
189
+
190
+ .action-buttons {
191
+ display: flex;
192
+ gap: 0.5rem;
193
+ margin-top: 1rem;
194
+ }
195
+
196
+ .copy-message-btn {
197
+ background: transparent !important;
198
+ border: 1px solid #4f46e5 !important;
199
+ color: #4f46e5 !important;
200
+ padding: 0.25rem 0.75rem !important;
201
+ border-radius: 4px !important;
202
+ font-size: 0.75rem !important;
203
+ cursor: pointer !important;
204
+ margin: 0 !important;
205
+ z-index: 100 !important;
206
+ }
207
+
208
+ .copy-message-btn:hover {
209
+ background: #4f46e5 !important;
210
+ color: white !important;
211
+ }
212
+
213
+ .copy-btn-wrapper {
214
+ position: absolute !important;
215
+ bottom: 8px !important;
216
+ right: 8px !important;
217
+ z-index: 100 !important;
218
+ margin-left: 12px !important;
219
+ }
220
+
221
+ .message-content-wrapper {
222
+ padding-right: 80px !important;
223
+ margin-bottom: 8px !important;
224
+ }
225
+
226
+ [data-testid="bot"],
227
+ [data-testid="user"],
228
+ .message-wrap {
229
+ position: relative !important;
230
+ min-height: 40px;
231
+ padding-bottom: 40px !important;
232
+ }
233
+
234
+ .chat-header-row {
235
+ display: flex !important;
236
+ justify-content: space-between !important;
237
+ align-items: center !important;
238
+ margin-bottom: 1rem !important;
239
+ gap: 1rem !important;
240
+ }
241
+
242
+ .chat-header-row button.plus-button {
243
+ flex: 0 0 32px !important;
244
+ }
245
+
246
+ .chat-title {
247
+ margin: 0 !important;
248
+ flex: 1 !important;
249
+ }
250
+
251
+ .plus-button {
252
+ min-width: 48px !important;
253
+ min-height: 48px !important;
254
+ padding: 0 14px !important;
255
+ border-radius: 9999px !important;
256
+ background-color: var(--brand) !important;
257
+ color: white !important;
258
+ border: none !important;
259
+ font-size: 0.875rem !important;
260
+ font-weight: 600 !important;
261
+ line-height: 1 !important;
262
+ display: inline-flex !important;
263
+ align-items: center !important;
264
+ justify-content: center !important;
265
+ cursor: pointer !important;
266
+ transition: background-color 0.2s ease !important;
267
+ flex-shrink: 0 !important;
268
+ flex-grow: 0 !important;
269
+ box-sizing: border-box !important;
270
+ }
271
+
272
+ .plus-button:hover {
273
+ background-color: #4338ca !important;
274
+ }
275
+
276
+ .gradio-chatbot button[title*="Copy"],
277
+ .gradio-chatbot button[aria-label*="Copy"],
278
+ .message-wrap button[title*="Copy"],
279
+ .message-wrap button[aria-label*="Copy"]:not(.copy-message-btn) {
280
+ display: none !important;
281
+ }
job_writer_ui/static/js.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ JavaScript code for interactive features.
3
+
4
+ This module contains custom JavaScript for adding copy buttons
5
+ to each chat message.
6
+ """
7
+
8
+ CUSTOM_JS = """
9
+ (function() {
10
+ 'use strict';
11
+
12
+ var processedIds = new Set();
13
+
14
+ function addCopyButtons() {
15
+ var messages = document.querySelectorAll('[data-testid="bot"], [data-testid="user"], .message-wrap');
16
+
17
+ messages.forEach(function(messageEl) {
18
+ var messageId = messageEl.getAttribute('data-testid') || messageEl.className || messageEl.id || messageEl.outerHTML.substring(0, 100);
19
+
20
+ if (processedIds.has(messageId)) {
21
+ return;
22
+ }
23
+
24
+ if (messageEl.querySelector('.copy-message-btn')) {
25
+ processedIds.add(messageId);
26
+ return;
27
+ }
28
+
29
+ var parentContainer = messageEl.closest('.message, .message-wrap, [class*="message"]');
30
+ if (parentContainer && parentContainer.querySelector('.copy-message-btn')) {
31
+ processedIds.add(messageId);
32
+ return;
33
+ }
34
+
35
+ var messageContent = messageEl.querySelector('.markdown, .message-content, [data-testid="bot"] .markdown, [data-testid="user"] .markdown');
36
+ if (!messageContent) {
37
+ return;
38
+ }
39
+
40
+ // Only add copy button to bot messages (draft/final content), not user messages or system messages
41
+ var isBotMessage = messageEl.hasAttribute('data-testid') && messageEl.getAttribute('data-testid') === 'bot';
42
+ var isUserMessage = messageEl.hasAttribute('data-testid') && messageEl.getAttribute('data-testid') === 'user';
43
+
44
+ // Skip if it's a user message or if message doesn't contain draft/final content
45
+ if (isUserMessage) {
46
+ return;
47
+ }
48
+
49
+ // Check if message contains draft/final content markers
50
+ var messageText = messageContent.innerText || messageContent.textContent || '';
51
+ var hasDraftContent = messageText.includes('**Draft**') ||
52
+ messageText.includes('**Final Version**') ||
53
+ messageText.includes('**Approved**') ||
54
+ (isBotMessage && messageText.trim().length > 50);
55
+
56
+ if (!hasDraftContent) {
57
+ return;
58
+ }
59
+
60
+ var existingButtons = messageEl.querySelectorAll('button[title*="Copy"], button[title*="copy"], button[aria-label*="Copy" i], button[aria-label*="copy" i], .copy-message-btn');
61
+ if (existingButtons.length > 0) {
62
+ processedIds.add(messageId);
63
+ return;
64
+ }
65
+
66
+ var parent = messageEl.parentElement;
67
+ while (parent && parent !== document.body) {
68
+ var parentButtons = parent.querySelectorAll('button[title*="Copy"], button[title*="copy"], button[aria-label*="Copy" i]');
69
+ if (parentButtons.length > 0 && parent.classList.contains('message')) {
70
+ processedIds.add(messageId);
71
+ return;
72
+ }
73
+ parent = parent.parentElement;
74
+ }
75
+
76
+ var copyBtn = document.createElement('button');
77
+ copyBtn.className = 'copy-message-btn';
78
+ copyBtn.type = 'button';
79
+ copyBtn.textContent = 'Copy';
80
+ copyBtn.setAttribute('aria-label', 'Copy message content');
81
+
82
+ copyBtn.onclick = function(e) {
83
+ e.preventDefault();
84
+ e.stopPropagation();
85
+
86
+ var textToCopy = messageContent.innerText || messageContent.textContent || '';
87
+ textToCopy = textToCopy.trim();
88
+
89
+ if (!textToCopy) {
90
+ return;
91
+ }
92
+
93
+ if (navigator.clipboard && navigator.clipboard.writeText) {
94
+ navigator.clipboard.writeText(textToCopy).then(function() {
95
+ copyBtn.textContent = 'Copied!';
96
+ setTimeout(function() {
97
+ copyBtn.textContent = 'Copy';
98
+ }, 2000);
99
+ }).catch(function() {
100
+ fallbackCopy(textToCopy, copyBtn);
101
+ });
102
+ } else {
103
+ fallbackCopy(textToCopy, copyBtn);
104
+ }
105
+ };
106
+
107
+ var buttonWrapper = document.createElement('div');
108
+ buttonWrapper.className = 'copy-btn-wrapper';
109
+ buttonWrapper.style.cssText = 'position: absolute; bottom: 8px; right: 8px; z-index: 10; margin-left: 12px;';
110
+ buttonWrapper.appendChild(copyBtn);
111
+
112
+ // Add spacing wrapper around message content
113
+ if (!messageContent.classList.contains('message-content-wrapper')) {
114
+ messageContent.classList.add('message-content-wrapper');
115
+ messageContent.style.paddingRight = '80px';
116
+ messageContent.style.marginBottom = '8px';
117
+ }
118
+
119
+ var container = messageEl;
120
+ if (window.getComputedStyle(container).position === 'static') {
121
+ container.style.position = 'relative';
122
+ }
123
+
124
+ container.appendChild(buttonWrapper);
125
+ processedIds.add(messageId);
126
+ });
127
+ }
128
+
129
+ function fallbackCopy(text, btn) {
130
+ var textArea = document.createElement('textarea');
131
+ textArea.value = text;
132
+ textArea.style.position = 'fixed';
133
+ textArea.style.left = '-9999px';
134
+ textArea.style.opacity = '0';
135
+ document.body.appendChild(textArea);
136
+ textArea.select();
137
+ try {
138
+ document.execCommand('copy');
139
+ btn.textContent = 'Copied!';
140
+ } catch (err) {
141
+ btn.textContent = 'Copy failed';
142
+ }
143
+ document.body.removeChild(textArea);
144
+ setTimeout(function() {
145
+ btn.textContent = 'Copy';
146
+ }, 2000);
147
+ }
148
+
149
+ function initCopyButtons() {
150
+ addCopyButtons();
151
+ }
152
+
153
+ if (document.readyState === 'loading') {
154
+ document.addEventListener('DOMContentLoaded', initCopyButtons);
155
+ } else {
156
+ initCopyButtons();
157
+ }
158
+
159
+ var observer = new MutationObserver(function(mutations) {
160
+ var shouldUpdate = false;
161
+ mutations.forEach(function(mutation) {
162
+ if (mutation.addedNodes.length > 0) {
163
+ shouldUpdate = true;
164
+ }
165
+ });
166
+ if (shouldUpdate) {
167
+ setTimeout(addCopyButtons, 300);
168
+ }
169
+ });
170
+
171
+ observer.observe(document.body, {
172
+ childList: true,
173
+ subtree: true
174
+ });
175
+ })();
176
+ """
job_writer_ui/ui/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UI module for Gradio interface components and handlers.
3
+
4
+ This package provides:
5
+ - UI component helpers
6
+ - Event handlers for user interactions
7
+ """
8
+
9
+ from ui.handlers import (
10
+ approve_final_content,
11
+ execute_workflow,
12
+ handle_feedback,
13
+ show_chat_interface,
14
+ )
15
+
16
+ __all__ = [
17
+ "approve_final_content",
18
+ "execute_workflow",
19
+ "handle_feedback",
20
+ "show_chat_interface",
21
+ ]
job_writer_ui/ui/handlers.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UI event handlers.
3
+
4
+ This module provides handler functions that wrap workflow functions
5
+ with UI logic for chat interface formatting and event handling.
6
+
7
+ It is written against the Gradio Chatbot \"messages\" format, but remains
8
+ backwards-compatible with legacy tuple-style histories.
9
+ """
10
+
11
+ from typing import Any, Dict, List, Tuple, Union
12
+
13
+ import gradio as gr
14
+
15
+ from api.workflow import resume_with_feedback, start_generation
16
+ from utils.formatters import extract_content_from_result, format_draft_message
17
+
18
+ # Error message templates
19
+ ERROR_MESSAGE_TEMPLATE = (
20
+ "Error: {error_msg}\n\nPlease try again or start a new session."
21
+ )
22
+ UNEXPECTED_ERROR_MESSAGE = "Error: Unexpected response format. Please try again."
23
+
24
+ # Processing messages
25
+ PROCESSING_MESSAGE = "Analyzing job posting and resume. This will take a moment..."
26
+ FEEDBACK_PROCESSING_MESSAGE = "Processing your feedback and revising the content..."
27
+ APPROVED_MESSAGE = "Content approved."
28
+
29
+
30
+ ChatHistory = List[Any]
31
+
32
+
33
+ def _to_messages(history: Union[None, ChatHistory]) -> ChatHistory:
34
+ """
35
+ Normalize chat history to Gradio \"messages\" format:
36
+ List[Dict[str, Any]] with \"role\" and \"content\" keys.
37
+
38
+ Supports legacy tuple/list format: [[user, assistant], ...].
39
+ """
40
+ if not history:
41
+ return []
42
+
43
+ # Already messages format
44
+ if isinstance(history[0], dict) and "role" in history[0]:
45
+ return history
46
+
47
+ # Legacy [[user, assistant], ...] format
48
+ messages: ChatHistory = []
49
+ for pair in history:
50
+ if isinstance(pair, (list, tuple)) and len(pair) == 2:
51
+ user, assistant = pair
52
+ if user:
53
+ messages.append({"role": "user", "content": user})
54
+ if assistant:
55
+ messages.append({"role": "assistant", "content": assistant})
56
+ return messages
57
+
58
+
59
+ def _replace_last_assistant_content(
60
+ messages: ChatHistory, new_content: str
61
+ ) -> ChatHistory:
62
+ """
63
+ Replace the content of the last assistant message in a messages-style history.
64
+ If none is found, append a new assistant message.
65
+ """
66
+ if not messages:
67
+ return [{"role": "assistant", "content": new_content}]
68
+
69
+ for idx in range(len(messages) - 1, -1, -1):
70
+ msg = messages[idx]
71
+ if isinstance(msg, dict) and msg.get("role") == "assistant":
72
+ updated = messages.copy()
73
+ updated[idx] = {"role": "assistant", "content": new_content}
74
+ return updated
75
+
76
+ # If we didn't find an assistant message, append one
77
+ return messages + [{"role": "assistant", "content": new_content}]
78
+
79
+
80
+ def _get_last_assistant_content(messages: ChatHistory) -> str:
81
+ """
82
+ Get the content of the last assistant message from either messages-style
83
+ or legacy tuple-style history.
84
+ """
85
+ if not messages:
86
+ return ""
87
+
88
+ # Messages format
89
+ if isinstance(messages[-1], dict):
90
+ for msg in reversed(messages):
91
+ if isinstance(msg, dict) and msg.get("role") == "assistant":
92
+ return msg.get("content") or ""
93
+ return ""
94
+
95
+ # Legacy [[user, assistant], ...] format
96
+ last = messages[-1]
97
+ if isinstance(last, (list, tuple)) and len(last) == 2:
98
+ return last[1] or ""
99
+ return ""
100
+
101
+
102
+ def show_chat_interface(
103
+ chat_history: ChatHistory, state: Dict[str, Any], content_type: str
104
+ ) -> Tuple[ChatHistory, Dict[str, Any], Dict, Dict, Dict, Dict]:
105
+ """
106
+ Show chat interface and display processing message.
107
+
108
+ Args:
109
+ chat_history: Current chat history
110
+ state: Current state dict
111
+ content_type: Type of content to generate
112
+
113
+ Returns:
114
+ Tuple of (updated_chat_history with processing message, updated_state,
115
+ form_visibility, chat_visibility, send_btn_update, approve_btn_update)
116
+ """
117
+ # Normalize to messages format and add processing message
118
+ messages = _to_messages(chat_history)
119
+ new_history: ChatHistory = messages + [
120
+ {"role": "assistant", "content": PROCESSING_MESSAGE}
121
+ ]
122
+
123
+ # Store content_type in state
124
+ updated_state = state.copy()
125
+ updated_state["content_type"] = content_type
126
+
127
+ # Transition to chat phase
128
+ form_visibility = gr.update(visible=False)
129
+ chat_visibility = gr.update(visible=True)
130
+
131
+ # Disable feedback + approve while generation runs
132
+ send_btn_update = gr.update(interactive=False)
133
+ approve_btn_update = gr.update(interactive=False)
134
+
135
+ return (
136
+ new_history,
137
+ updated_state,
138
+ form_visibility,
139
+ chat_visibility,
140
+ send_btn_update,
141
+ approve_btn_update,
142
+ )
143
+
144
+
145
+ def execute_workflow(
146
+ job_url: str,
147
+ content_type: str,
148
+ resume_url: str,
149
+ chat_history: ChatHistory,
150
+ state: Dict[str, Any],
151
+ ) -> Tuple[ChatHistory, Dict[str, Any], Dict, Dict, Dict]:
152
+ """
153
+ Execute workflow and update chat with response.
154
+
155
+ Args:
156
+ job_url: Job posting URL
157
+ content_type: Type of content to generate
158
+ resume_url: Resume file URL (optional)
159
+ chat_history: Current chat history (with processing message)
160
+ state: Current state dict
161
+
162
+ Returns:
163
+ Tuple of (updated_chat_history, updated_state, send_btn_update, approve_btn_update, cancel_btn_update)
164
+ """
165
+ messages = _to_messages(chat_history)
166
+
167
+ # Call start_generation
168
+ result_dict, updated_state = start_generation(
169
+ job_url, content_type, state, resume_url
170
+ )
171
+
172
+ # Convert response to chat format
173
+ new_history = messages.copy()
174
+ if result_dict.get("error"):
175
+ error_msg = result_dict.get(
176
+ "details", result_dict.get("error", "Unknown error")
177
+ )
178
+ error_content = ERROR_MESSAGE_TEMPLATE.format(error_msg=error_msg)
179
+ new_history = _replace_last_assistant_content(new_history, error_content)
180
+ send_btn_update = gr.update(interactive=False)
181
+ approve_btn_update = gr.update(interactive=False)
182
+ elif result_dict.get("status") == "interrupted":
183
+ draft = result_dict.get("draft", "")
184
+ formatted_draft = format_draft_message(draft, status="draft")
185
+ new_history = _replace_last_assistant_content(new_history, formatted_draft)
186
+ send_btn_update = gr.update(interactive=True)
187
+ approve_btn_update = gr.update(interactive=True)
188
+ elif result_dict.get("status") == "completed":
189
+ result = result_dict.get("result", "")
190
+ content = extract_content_from_result(result)
191
+ formatted_content = format_draft_message(content, status="final")
192
+ new_history = _replace_last_assistant_content(new_history, formatted_content)
193
+ send_btn_update = gr.update(interactive=True)
194
+ approve_btn_update = gr.update(interactive=True)
195
+ else:
196
+ new_history = _replace_last_assistant_content(
197
+ new_history, UNEXPECTED_ERROR_MESSAGE
198
+ )
199
+ send_btn_update = gr.update(interactive=False)
200
+ approve_btn_update = gr.update(interactive=False)
201
+
202
+ # Make cancel button visible if there's a run_id in state
203
+ cancel_btn_update = gr.update(visible=bool(updated_state.get("run_id")))
204
+
205
+ return (
206
+ new_history,
207
+ updated_state,
208
+ send_btn_update,
209
+ approve_btn_update,
210
+ cancel_btn_update,
211
+ )
212
+
213
+
214
+ def handle_feedback(
215
+ chat_history: ChatHistory, feedback_text: str, state: Dict[str, Any]
216
+ ) -> Tuple[ChatHistory, str, Dict[str, Any], Dict]:
217
+ """
218
+ Handle user feedback and update chat.
219
+
220
+ Args:
221
+ chat_history: Current chat history
222
+ feedback_text: User feedback text
223
+ state: Current state dict
224
+
225
+ Returns:
226
+ Tuple of (updated_chat_history, cleared_feedback, updated_state, cancel_btn_update)
227
+ """
228
+ messages = _to_messages(chat_history)
229
+
230
+ # Step 1: Add user feedback message
231
+ feedback_content = feedback_text.strip()
232
+ if feedback_content:
233
+ messages = messages + [{"role": "user", "content": feedback_content}]
234
+
235
+ # Step 2: Add processing message
236
+ new_history = messages + [
237
+ {"role": "assistant", "content": FEEDBACK_PROCESSING_MESSAGE}
238
+ ]
239
+
240
+ # Step 3: Call resume_with_feedback (preserves existing function)
241
+ result_dict, updated_state = resume_with_feedback(feedback_text, state)
242
+
243
+ # Step 4: Convert response to chat format
244
+ if result_dict.get("error"):
245
+ error_msg = result_dict.get(
246
+ "details", result_dict.get("error", "Unknown error")
247
+ )
248
+ error_content = ERROR_MESSAGE_TEMPLATE.format(error_msg=error_msg)
249
+ new_history = _replace_last_assistant_content(new_history, error_content)
250
+ elif result_dict.get("status") == "completed":
251
+ result = result_dict.get("result", "")
252
+ content = extract_content_from_result(result)
253
+ formatted_content = format_draft_message(content, status="final")
254
+ new_history = _replace_last_assistant_content(new_history, formatted_content)
255
+ else:
256
+ new_history = _replace_last_assistant_content(
257
+ new_history, UNEXPECTED_ERROR_MESSAGE
258
+ )
259
+
260
+ # Step 5: Clear feedback input
261
+ cleared_feedback = ""
262
+
263
+ # Update cancel button visibility based on whether there's an active run_id
264
+ cancel_btn_update = gr.update(visible=bool(updated_state.get("run_id")))
265
+
266
+ return new_history, cleared_feedback, updated_state, cancel_btn_update
267
+
268
+
269
+ def approve_final_content(
270
+ chat_history: ChatHistory, state: Dict[str, Any]
271
+ ) -> Tuple[ChatHistory, Dict, Dict]:
272
+ """
273
+ Mark content as approved and show confirmation.
274
+
275
+ Args:
276
+ chat_history: Current chat history
277
+ state: Current state dict
278
+
279
+ Returns:
280
+ Tuple of (updated_chat_history, feedback_visibility, button_visibility)
281
+ """
282
+ # Content extraction markers
283
+ content_start_marker = "---\n\n"
284
+ content_end_marker = "\n\n---"
285
+
286
+ messages = _to_messages(chat_history)
287
+
288
+ # Extract last assistant message and mark as approved
289
+ last_message = _get_last_assistant_content(messages)
290
+ if last_message:
291
+ # Replace status with "Approved" if it's a draft message
292
+ if "**Draft**" in last_message or "**Final Version**" in last_message:
293
+ content_start = last_message.find(content_start_marker) + len(
294
+ content_start_marker
295
+ )
296
+ content_end = last_message.rfind(content_end_marker)
297
+ if (
298
+ content_start > len(content_start_marker) - 1
299
+ and content_end > content_start
300
+ ):
301
+ content = last_message[content_start:content_end]
302
+ approved_message = format_draft_message(content, status="approved")
303
+ new_history = _replace_last_assistant_content(
304
+ messages, approved_message
305
+ )
306
+ else:
307
+ new_history = messages + [
308
+ {"role": "assistant", "content": APPROVED_MESSAGE}
309
+ ]
310
+ else:
311
+ new_history = messages + [
312
+ {"role": "assistant", "content": APPROVED_MESSAGE}
313
+ ]
314
+ else:
315
+ new_history = messages + [{"role": "assistant", "content": APPROVED_MESSAGE}]
316
+
317
+ # Hide feedback input, show action buttons
318
+ feedback_visibility = gr.update(visible=False)
319
+ button_visibility = gr.update(visible=True)
320
+
321
+ return new_history, feedback_visibility, button_visibility
job_writer_ui/utils/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utility modules for Job Application Writer UI.
3
+
4
+ This package provides:
5
+ - Message formatting utilities
6
+ - Content extraction and file operations
7
+ - State management helpers
8
+ """
9
+
10
+ from utils.content import download_content_as_file, extract_message_content
11
+ from utils.formatters import extract_content_from_result, format_draft_message
12
+ from utils.state import reset_session
13
+
14
+ __all__ = [
15
+ "download_content_as_file",
16
+ "extract_content_from_result",
17
+ "extract_message_content",
18
+ "format_draft_message",
19
+ "reset_session",
20
+ ]
job_writer_ui/utils/content.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Content extraction and file operations.
3
+
4
+ This module provides functions for extracting content from chat history
5
+ and creating downloadable files.
6
+ """
7
+
8
+ import tempfile
9
+ from datetime import datetime
10
+ from typing import Any, Dict, List
11
+
12
+ # Content type mapping for file naming
13
+ CONTENT_TYPE_MAP = {
14
+ "cover_letter": "cover_letter",
15
+ "bullets": "bullets",
16
+ "linkedin_note": "linkedin_note",
17
+ }
18
+
19
+ # Default content type
20
+ DEFAULT_CONTENT_TYPE = "content"
21
+
22
+ # Content extraction markers
23
+ CONTENT_START_MARKER = "---\n\n"
24
+ CONTENT_END_MARKER = "\n\n---"
25
+
26
+ # Default content fallback
27
+ DEFAULT_CONTENT = "No content available."
28
+
29
+
30
+ def _get_last_assistant_message_content(
31
+ chat_history: List[Any], message_index: int = -1
32
+ ) -> str:
33
+ """
34
+ Get assistant message content from either messages-style or
35
+ legacy tuple-style chat history.
36
+ """
37
+ if not chat_history:
38
+ return ""
39
+
40
+ # Messages format: list of dicts with role/content
41
+ if isinstance(chat_history[0], dict):
42
+ # Use explicit index if provided, otherwise search from the end
43
+ history = chat_history
44
+ if 0 <= message_index < len(history):
45
+ candidates = [history[message_index]]
46
+ else:
47
+ candidates = reversed(history)
48
+ for msg in candidates:
49
+ if isinstance(msg, dict) and msg.get("role") == "assistant":
50
+ return msg.get("content") or ""
51
+ return ""
52
+
53
+ # Legacy [[user, assistant], ...] format
54
+ try:
55
+ target_message = (
56
+ chat_history[message_index][1]
57
+ if chat_history[message_index][1]
58
+ else ""
59
+ )
60
+ except (IndexError, KeyError, TypeError):
61
+ return ""
62
+
63
+ return target_message or ""
64
+
65
+
66
+ def extract_message_content(chat_history: List[Any], message_index: int = -1) -> str:
67
+ """
68
+ Extract content from a specific message for clipboard.
69
+
70
+ Args:
71
+ chat_history: Current chat history
72
+ message_index: Index of message to extract (-1 for last message)
73
+
74
+ Returns:
75
+ Content string extracted from message
76
+ """
77
+ target_message = _get_last_assistant_message_content(
78
+ chat_history, message_index=message_index
79
+ )
80
+ if not target_message:
81
+ return ""
82
+
83
+ # Extract content between markdown separators if present
84
+ content_start = target_message.find(CONTENT_START_MARKER) + len(
85
+ CONTENT_START_MARKER
86
+ )
87
+ content_end = target_message.rfind(CONTENT_END_MARKER)
88
+
89
+ if content_start > len(CONTENT_START_MARKER) - 1 and content_end > content_start:
90
+ return target_message[content_start:content_end]
91
+
92
+ return target_message
93
+
94
+
95
+ def download_content_as_file(chat_history: List, state: Dict) -> str:
96
+ """
97
+ Create downloadable file from last message content.
98
+
99
+ Args:
100
+ chat_history: Current chat history
101
+ state: Current state dict (contains content_type)
102
+
103
+ Returns:
104
+ Path to temporary file created for download
105
+
106
+ Raises:
107
+ OSError: If file creation fails
108
+ """
109
+ content = extract_message_content(chat_history)
110
+ if not content:
111
+ content = DEFAULT_CONTENT
112
+
113
+ # Get content_type from state
114
+ content_type = state.get("content_type", DEFAULT_CONTENT_TYPE)
115
+
116
+ # Generate suffix based on content type
117
+ type_str = CONTENT_TYPE_MAP.get(content_type, DEFAULT_CONTENT_TYPE)
118
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
119
+ suffix = f"_{type_str}_{timestamp}.txt"
120
+
121
+ # Create temporary file with descriptive suffix
122
+ try:
123
+ temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False)
124
+ temp_file.write(content)
125
+ temp_file.close()
126
+ return temp_file.name
127
+ except OSError as error:
128
+ raise OSError(f"Failed to create download file: {error}") from error
job_writer_ui/utils/formatters.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Message formatting utilities.
3
+
4
+ This module provides functions for formatting content and messages
5
+ for display in the chat interface.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ # Status label mapping
11
+ STATUS_LABELS = {
12
+ "draft": "Draft",
13
+ "final": "Final Version",
14
+ "approved": "Approved",
15
+ }
16
+
17
+ # Default status
18
+ DEFAULT_STATUS = "Draft"
19
+
20
+ # Message templates
21
+ FEEDBACK_PROMPT = "*You can provide feedback below or approve this version.*"
22
+
23
+
24
+ def format_draft_message(draft_content: str, status: str = DEFAULT_STATUS) -> str:
25
+ """
26
+ Format draft content for chat display.
27
+
28
+ Args:
29
+ draft_content: The draft content to display
30
+ status: Status label ("draft", "final", "approved")
31
+
32
+ Returns:
33
+ Formatted message string with markdown formatting
34
+ """
35
+ status_label = STATUS_LABELS.get(status, DEFAULT_STATUS)
36
+
37
+ return f"""**{status_label}**
38
+
39
+ ---
40
+
41
+ {draft_content}
42
+
43
+ ---
44
+
45
+ {FEEDBACK_PROMPT}"""
46
+
47
+
48
+ def extract_content_from_result(result: Any) -> str:
49
+ """
50
+ Extract content string from result dict or object.
51
+
52
+ Attempts to extract content from various result structures
53
+ returned by the workflow.
54
+
55
+ Args:
56
+ result: Result dict or string from workflow
57
+
58
+ Returns:
59
+ Content string extracted from result structure
60
+ """
61
+ if isinstance(result, str):
62
+ return result
63
+
64
+ if isinstance(result, dict):
65
+ # Try common result keys
66
+ if "content" in result:
67
+ return str(result["content"])
68
+
69
+ if "text" in result:
70
+ return str(result["text"])
71
+
72
+ if "output_data" in result:
73
+ output = result["output_data"]
74
+ if isinstance(output, str):
75
+ return output
76
+ if isinstance(output, dict) and "content" in output:
77
+ return str(output["content"])
78
+
79
+ # Return string representation of entire dict if no specific key found
80
+ return str(result)
81
+
82
+ return str(result)
job_writer_ui/utils/state.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ State management helpers.
3
+
4
+ This module provides functions for managing application state,
5
+ including session resets and state initialization.
6
+ """
7
+
8
+ from typing import Dict, List, Tuple
9
+
10
+ import gradio as gr
11
+
12
+
13
+ def reset_session() -> Tuple[Dict, Dict, List, Dict]:
14
+ """
15
+ Reset UI to form phase and clear state.
16
+
17
+ Returns:
18
+ Tuple of (form_visibility, chat_visibility, empty_chat_history, empty_state)
19
+ """
20
+ form_visibility = gr.update(visible=True)
21
+ chat_visibility = gr.update(visible=False)
22
+ empty_history: List = []
23
+ empty_state: Dict = {}
24
+
25
+ return form_visibility, chat_visibility, empty_history, empty_state
job_writer_ui/utils/validation.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Form input validation.
2
+
3
+ Uses Pydantic to validate user inputs in the Gradio form.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+ from pydantic import BaseModel, EmailStr, HttpUrl, ValidationError
11
+
12
+
13
+ class JobWriterForm(BaseModel):
14
+ job_url: HttpUrl
15
+ content_type: str
16
+ resume_url: Optional[HttpUrl] = None
17
+ email: Optional[EmailStr] = None
18
+
19
+
20
+ def validate_form_inputs(
21
+ job_url: str | None,
22
+ content_type: str | None,
23
+ resume_url: str | None,
24
+ email: str | None,
25
+ ) -> tuple[bool, str]:
26
+ """Validate inputs and return (is_valid, error_message_markdown)."""
27
+
28
+ # Normalize empties
29
+ job_url = (job_url or "").strip()
30
+ content_type = (content_type or "").strip()
31
+ resume_url = (resume_url or "").strip()
32
+ email = (email or "").strip()
33
+
34
+ # Enablement rules: job_url + content_type required.
35
+ if not job_url or not content_type:
36
+ return False, ""
37
+
38
+ # Pydantic validation for requested fields
39
+ try:
40
+ JobWriterForm(
41
+ job_url=job_url,
42
+ content_type=content_type,
43
+ resume_url=resume_url or None,
44
+ email=email or None,
45
+ )
46
+ except ValidationError as exc:
47
+ # Show first couple of errors as a compact list.
48
+ items = []
49
+ for err in exc.errors()[:3]:
50
+ loc = ".".join(str(p) for p in err.get("loc", []))
51
+ msg = err.get("msg", "Invalid value")
52
+ items.append(f"- **{loc}**: {msg}")
53
+ details = "\n".join(items) if items else "- Invalid input"
54
+ return False, f"**Please fix the following:**\n{details}"
55
+
56
+ return True, ""