shak3008 commited on
Commit
3ec36d7
·
1 Parent(s): 4431635

feat: live stage progression during workflow execution

Browse files

Each workflow node now writes its current stage to the database via
report_progress(). The frontend polls GET /workflows/{id} every 3s
and the API reads the PROGRESS checkpoint to return the live current_node.

Result: the pipeline visualization shows stages lighting up one by one
during processing (Extract ✓ → Chunk ✓ → Embed pulsing → ...) instead
of jumping from PENDING to all-green COMPLETED.

Stages reporting progress: extract, chunk, embed, classify, knowledge,
reconcile, validate, decision. Each writes to a PROGRESS checkpoint
with its own DB session (committed immediately, visible to API polls).

backend/app/api/workflows.py CHANGED
@@ -37,8 +37,16 @@ def _workflow_response(workflow: WorkflowRun) -> dict:
37
  elif workflow.status == WorkflowStatus.PENDING:
38
  current_node = "PENDING"
39
  else:
40
- # RUNNING — use latest checkpoint
41
- current_node = completed_stages[-1] if completed_stages else "EXTRACTION"
 
 
 
 
 
 
 
 
42
 
43
  # Get validation results from checkpoint state if available
44
  validation_results = None
 
37
  elif workflow.status == WorkflowStatus.PENDING:
38
  current_node = "PENDING"
39
  else:
40
+ # RUNNING — read live progress from PROGRESS checkpoint
41
+ progress_cp = None
42
+ for cp in reversed(checkpoints):
43
+ if cp.agent_name == "PROGRESS":
44
+ progress_cp = cp
45
+ break
46
+ if progress_cp and progress_cp.state:
47
+ current_node = progress_cp.state.get("current_node", "EXTRACTION")
48
+ else:
49
+ current_node = "EXTRACTION"
50
 
51
  # Get validation results from checkpoint state if available
52
  validation_results = None
backend/app/workflow/nodes/chunk.py CHANGED
@@ -17,6 +17,9 @@ def chunk(
17
  tracker = get_or_create_tracker(str(state.workflow_run_id))
18
  tracker.start_stage("chunking")
19
 
 
 
 
20
  result = chunking_service.chunk_document(
21
  document_version_id=state.document_version_id,
22
  sections=state.extracted_sections,
 
17
  tracker = get_or_create_tracker(str(state.workflow_run_id))
18
  tracker.start_stage("chunking")
19
 
20
+ from app.workflow.progress import report_progress
21
+ report_progress(state.workflow_run_id, "CHUNKING")
22
+
23
  result = chunking_service.chunk_document(
24
  document_version_id=state.document_version_id,
25
  sections=state.extracted_sections,
backend/app/workflow/nodes/classify.py CHANGED
@@ -10,6 +10,9 @@ def classify(
10
 
11
  state.current_node = "CLASSIFICATION"
12
 
 
 
 
13
  state.document_type = "GENERAL"
14
 
15
  return state
 
10
 
11
  state.current_node = "CLASSIFICATION"
12
 
13
+ from app.workflow.progress import report_progress
14
+ report_progress(state.workflow_run_id, "CLASSIFICATION")
15
+
16
  state.document_type = "GENERAL"
17
 
18
  return state
backend/app/workflow/nodes/decision.py CHANGED
@@ -11,6 +11,9 @@ def decide(state: WorkflowState) -> WorkflowState:
11
 
12
  state.current_node = "DECISION"
13
 
 
 
 
14
  agent = DecisionAgent()
15
 
16
  state.decision = agent.decide(
 
11
 
12
  state.current_node = "DECISION"
13
 
14
+ from app.workflow.progress import report_progress
15
+ report_progress(state.workflow_run_id, "DECISION")
16
+
17
  agent = DecisionAgent()
18
 
19
  state.decision = agent.decide(
backend/app/workflow/nodes/embed.py CHANGED
@@ -22,6 +22,9 @@ def embed(
22
  tracker = get_or_create_tracker(str(state.workflow_run_id))
23
  tracker.start_stage("embedding")
24
 
 
 
 
25
  chunks = (
26
  db.query(DocumentChunk)
27
  .filter(
 
22
  tracker = get_or_create_tracker(str(state.workflow_run_id))
23
  tracker.start_stage("embedding")
24
 
25
+ from app.workflow.progress import report_progress
26
+ report_progress(state.workflow_run_id, "EMBEDDING")
27
+
28
  chunks = (
29
  db.query(DocumentChunk)
30
  .filter(
backend/app/workflow/nodes/extract.py CHANGED
@@ -17,6 +17,9 @@ def extract(state: WorkflowState) -> WorkflowState:
17
  tracker = get_or_create_tracker(str(state.workflow_run_id))
18
  tracker.start_stage("extraction")
19
 
 
 
 
20
  try:
21
  sections = extract_text_sections(
22
  state.document_path
 
17
  tracker = get_or_create_tracker(str(state.workflow_run_id))
18
  tracker.start_stage("extraction")
19
 
20
+ from app.workflow.progress import report_progress
21
+ report_progress(state.workflow_run_id, "EXTRACTION")
22
+
23
  try:
24
  sections = extract_text_sections(
25
  state.document_path
backend/app/workflow/nodes/knowledge.py CHANGED
@@ -93,6 +93,9 @@ def knowledge(
93
  state.metadata["knowledge_items_created"] = 0
94
  return state
95
 
 
 
 
96
  sections = []
97
 
98
  for index, section in enumerate(state.extracted_sections):
@@ -144,9 +147,8 @@ def knowledge(
144
  except Exception as first_err:
145
  # On failure, retry with fewer sections to reduce output size
146
  import logging
147
- logging.getLogger(__name__).warning(
148
- "Knowledge extraction failed, retrying with reduced input: %s", first_err
149
- )
150
  # Take only first half of sections to reduce output length
151
  half = max(1, len(sections) // 2)
152
  reduced_prompt = f"{KNOWLEDGE_PROMPT}\n" + "\n".join(sections[:half])
@@ -154,8 +156,9 @@ def knowledge(
154
  raw_result = structured_model.invoke(reduced_prompt)
155
  response = raw_result["parsed"]
156
  raw_response = raw_result.get("raw")
157
- except Exception:
158
  # Final fallback: return empty extraction rather than crash
 
159
  state.metadata["knowledge_items_created"] = 0
160
  state.metadata["extraction_error"] = str(first_err)[:200]
161
  tracker.end_stage("knowledge_extraction")
@@ -168,13 +171,29 @@ def knowledge(
168
  usage = meta.get("token_usage") or meta.get("usage") or {}
169
  input_tokens = usage.get("prompt_tokens", 0) or usage.get("input_tokens", 0)
170
  output_tokens = usage.get("completion_tokens", 0) or usage.get("output_tokens", 0)
171
- tracker.record_llm_usage(
172
- "knowledge_extraction",
173
- input_tokens=input_tokens,
174
- output_tokens=output_tokens,
175
- )
176
- except Exception:
177
- pass # Non-fatal
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
  tracker.end_stage("knowledge_extraction")
180
 
 
93
  state.metadata["knowledge_items_created"] = 0
94
  return state
95
 
96
+ from app.workflow.progress import report_progress
97
+ report_progress(state.workflow_run_id, "KNOWLEDGE_EXTRACTION")
98
+
99
  sections = []
100
 
101
  for index, section in enumerate(state.extracted_sections):
 
147
  except Exception as first_err:
148
  # On failure, retry with fewer sections to reduce output size
149
  import logging
150
+ logger = logging.getLogger(__name__)
151
+ logger.warning("Knowledge extraction failed: %s", str(first_err)[:100])
 
152
  # Take only first half of sections to reduce output length
153
  half = max(1, len(sections) // 2)
154
  reduced_prompt = f"{KNOWLEDGE_PROMPT}\n" + "\n".join(sections[:half])
 
156
  raw_result = structured_model.invoke(reduced_prompt)
157
  response = raw_result["parsed"]
158
  raw_response = raw_result.get("raw")
159
+ except Exception as second_err:
160
  # Final fallback: return empty extraction rather than crash
161
+ logger.error("Knowledge extraction retry also failed: %s", str(second_err)[:100])
162
  state.metadata["knowledge_items_created"] = 0
163
  state.metadata["extraction_error"] = str(first_err)[:200]
164
  tracker.end_stage("knowledge_extraction")
 
171
  usage = meta.get("token_usage") or meta.get("usage") or {}
172
  input_tokens = usage.get("prompt_tokens", 0) or usage.get("input_tokens", 0)
173
  output_tokens = usage.get("completion_tokens", 0) or usage.get("output_tokens", 0)
174
+ if input_tokens or output_tokens:
175
+ tracker.record_llm_usage(
176
+ "knowledge_extraction",
177
+ input_tokens=input_tokens,
178
+ output_tokens=output_tokens,
179
+ )
180
+ else:
181
+ import logging
182
+ logging.getLogger(__name__).warning("Token usage was 0. Meta: %s", meta)
183
+ elif raw_response and hasattr(raw_response, "usage_metadata"):
184
+ um = raw_response.usage_metadata
185
+ if um:
186
+ tracker.record_llm_usage(
187
+ "knowledge_extraction",
188
+ input_tokens=getattr(um, "input_tokens", 0) or 0,
189
+ output_tokens=getattr(um, "output_tokens", 0) or 0,
190
+ )
191
+ else:
192
+ import logging
193
+ logging.getLogger(__name__).warning("No raw_response or no response_metadata. raw_response type: %s", type(raw_response))
194
+ except Exception as e:
195
+ import logging
196
+ logging.getLogger(__name__).warning("Token capture exception: %s", e)
197
 
198
  tracker.end_stage("knowledge_extraction")
199
 
backend/app/workflow/nodes/reconcile.py CHANGED
@@ -21,6 +21,9 @@ def reconcile(
21
  tracker = get_or_create_tracker(str(state.workflow_run_id))
22
  tracker.start_stage("reconciliation")
23
 
 
 
 
24
  service = ReconciliationService(db)
25
 
26
  results = service.reconcile_document(
 
21
  tracker = get_or_create_tracker(str(state.workflow_run_id))
22
  tracker.start_stage("reconciliation")
23
 
24
+ from app.workflow.progress import report_progress
25
+ report_progress(state.workflow_run_id, "RECONCILIATION")
26
+
27
  service = ReconciliationService(db)
28
 
29
  results = service.reconcile_document(
backend/app/workflow/nodes/validate.py CHANGED
@@ -22,6 +22,9 @@ def validate(
22
  tracker = get_or_create_tracker(str(state.workflow_run_id))
23
  tracker.start_stage("validation")
24
 
 
 
 
25
  knowledge_repository = KnowledgeRepository(db)
26
  rule_repository = RuleRepository(db)
27
 
 
22
  tracker = get_or_create_tracker(str(state.workflow_run_id))
23
  tracker.start_stage("validation")
24
 
25
+ from app.workflow.progress import report_progress
26
+ report_progress(state.workflow_run_id, "VALIDATION")
27
+
28
  knowledge_repository = KnowledgeRepository(db)
29
  rule_repository = RuleRepository(db)
30
 
backend/app/workflow/progress.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Workflow progress tracking.
3
+
4
+ Persists the current node to the workflow_checkpoints table so the
5
+ frontend can poll and show live stage progression during execution.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from app.database.database import SessionLocal
10
+ from app.models.workflow_checkpoint import WorkflowCheckpoint
11
+
12
+
13
+ def report_progress(workflow_run_id, stage_name: str):
14
+ """
15
+ Write/update the current stage to the database.
16
+ Uses its own session so it commits immediately and is visible to API polls.
17
+ """
18
+ db = SessionLocal()
19
+ try:
20
+ # Upsert: replace the PROGRESS checkpoint
21
+ db.query(WorkflowCheckpoint).filter(
22
+ WorkflowCheckpoint.workflow_run_id == workflow_run_id,
23
+ WorkflowCheckpoint.agent_name == "PROGRESS",
24
+ ).delete(synchronize_session=False)
25
+
26
+ checkpoint = WorkflowCheckpoint(
27
+ workflow_run_id=workflow_run_id,
28
+ agent_name="PROGRESS",
29
+ state={"current_node": stage_name},
30
+ message=stage_name,
31
+ )
32
+ db.add(checkpoint)
33
+ db.commit()
34
+ except Exception:
35
+ db.rollback()
36
+ finally:
37
+ db.close()