feat: live stage progression during workflow execution
Browse filesEach 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 +10 -2
- backend/app/workflow/nodes/chunk.py +3 -0
- backend/app/workflow/nodes/classify.py +3 -0
- backend/app/workflow/nodes/decision.py +3 -0
- backend/app/workflow/nodes/embed.py +3 -0
- backend/app/workflow/nodes/extract.py +3 -0
- backend/app/workflow/nodes/knowledge.py +30 -11
- backend/app/workflow/nodes/reconcile.py +3 -0
- backend/app/workflow/nodes/validate.py +3 -0
- backend/app/workflow/progress.py +37 -0
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 —
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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__)
|
| 148 |
-
|
| 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 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|