shak3008 commited on
Commit
90c285f
Β·
1 Parent(s): 31cf797

fix: resolve workflow linking on resume, update proposals auto-commit, and validation baseline rules

Browse files
backend/app/agents/validation.py CHANGED
@@ -114,9 +114,16 @@ class RuleValidationAgent:
114
  failures = []
115
 
116
  for proposal in proposals:
117
- changes = proposal.proposed_changes or {}
118
  proposed = changes.get("proposed", changes)
119
- evidence = proposed.get("evidence")
 
 
 
 
 
 
 
120
 
121
  if not evidence:
122
  failures.append(
@@ -132,8 +139,8 @@ class RuleValidationAgent:
132
  ),
133
  "operator": "required_evidence",
134
  "message": (
135
- "Proposal does not contain evidence metadata "
136
- "in proposed_changes."
137
  ),
138
  "proposal_ids": [str(proposal.id)],
139
  }
@@ -389,22 +396,27 @@ class RuleValidationAgent:
389
 
390
  workspace_id = proposals[0].workspace_id
391
 
392
- # Check if workspace has existing knowledge
393
  try:
394
  from app.database.database import SessionLocal
395
- from app.models.knowledge_item import KnowledgeItem
396
  from app.services.embedding_service import EmbeddingService
397
 
398
  db = SessionLocal()
399
  try:
400
- existing_count = (
401
- db.query(KnowledgeItem)
402
- .filter(KnowledgeItem.workspace_id == workspace_id)
403
- .count()
 
 
 
 
 
404
  )
405
 
406
- # If workspace is empty, pass everything (first doc defines topic)
407
- if existing_count == 0:
408
  return [
409
  {
410
  "status": "PASS",
@@ -418,8 +430,8 @@ class RuleValidationAgent:
418
  ),
419
  "operator": "topic_relevance",
420
  "message": (
421
- "No existing knowledge to compare against. "
422
- "First documents define the workspace topic."
423
  ),
424
  "proposal_ids": [
425
  str(p.id) for p in proposals
@@ -429,14 +441,6 @@ class RuleValidationAgent:
429
 
430
  # Use embedding service for semantic similarity
431
  embedding_service = EmbeddingService()
432
-
433
- # Get existing knowledge titles+values for comparison
434
- existing_items = (
435
- db.query(KnowledgeItem.title, KnowledgeItem.value)
436
- .filter(KnowledgeItem.workspace_id == workspace_id)
437
- .limit(50)
438
- .all()
439
- )
440
  existing_text = " ".join(
441
  f"{item.title} {item.value}" for item in existing_items
442
  )
 
114
  failures = []
115
 
116
  for proposal in proposals:
117
+ changes = getattr(proposal, "proposed_changes", None) or {}
118
  proposed = changes.get("proposed", changes)
119
+ evidence = proposed.get("evidence") or changes.get("evidence")
120
+
121
+ ki = getattr(proposal, "knowledge_item", None)
122
+ if not evidence and ki and getattr(ki, "evidence", None):
123
+ evidence = [
124
+ {"quote": getattr(ev, "quote", ""), "page_number": getattr(ev, "page_number", None)}
125
+ for ev in ki.evidence
126
+ ]
127
 
128
  if not evidence:
129
  failures.append(
 
139
  ),
140
  "operator": "required_evidence",
141
  "message": (
142
+ "Proposal does not contain required evidence quote "
143
+ "or citation metadata."
144
  ),
145
  "proposal_ids": [str(proposal.id)],
146
  }
 
396
 
397
  workspace_id = proposals[0].workspace_id
398
 
399
+ # Check if workspace has existing active baseline knowledge
400
  try:
401
  from app.database.database import SessionLocal
402
+ from app.models.knowledge_item import KnowledgeItem, KnowledgeStatus
403
  from app.services.embedding_service import EmbeddingService
404
 
405
  db = SessionLocal()
406
  try:
407
+ # Query only ACTIVE (previously committed) items, excluding uncommitted PENDING items
408
+ existing_items = (
409
+ db.query(KnowledgeItem.title, KnowledgeItem.value)
410
+ .filter(
411
+ KnowledgeItem.workspace_id == workspace_id,
412
+ KnowledgeItem.status == KnowledgeStatus.ACTIVE,
413
+ )
414
+ .limit(50)
415
+ .all()
416
  )
417
 
418
+ # If workspace has no active baseline knowledge yet, pass (first doc establishes baseline)
419
+ if not existing_items:
420
  return [
421
  {
422
  "status": "PASS",
 
430
  ),
431
  "operator": "topic_relevance",
432
  "message": (
433
+ "No prior active knowledge to compare against. "
434
+ "First documents establish the workspace baseline."
435
  ),
436
  "proposal_ids": [
437
  str(p.id) for p in proposals
 
441
 
442
  # Use embedding service for semantic similarity
443
  embedding_service = EmbeddingService()
 
 
 
 
 
 
 
 
444
  existing_text = " ".join(
445
  f"{item.title} {item.value}" for item in existing_items
446
  )
backend/app/api/workflows.py CHANGED
@@ -92,7 +92,7 @@ def _sync_workflow_review_state(workflow: WorkflowRun, db: Session) -> None:
92
  finally:
93
  executor.close()
94
  except Exception:
95
- workflow.status = WorkflowStatus.COMPLETED
96
  if not workflow.completed_at:
97
  workflow.completed_at = datetime.now(timezone.utc)
98
  db.commit()
 
92
  finally:
93
  executor.close()
94
  except Exception:
95
+ workflow.status = WorkflowStatus.FAILED
96
  if not workflow.completed_at:
97
  workflow.completed_at = datetime.now(timezone.utc)
98
  db.commit()
backend/app/services/proposal_review_service.py CHANGED
@@ -262,7 +262,7 @@ class ProposalReviewService:
262
  executor.resume(workflow)
263
  except Exception:
264
  from datetime import datetime, timezone
265
- workflow.status = WorkflowStatus.COMPLETED
266
  if not workflow.completed_at:
267
  workflow.completed_at = datetime.now(timezone.utc)
268
  self.db.commit()
 
262
  executor.resume(workflow)
263
  except Exception:
264
  from datetime import datetime, timezone
265
+ workflow.status = WorkflowStatus.FAILED
266
  if not workflow.completed_at:
267
  workflow.completed_at = datetime.now(timezone.utc)
268
  self.db.commit()
backend/app/services/reconciliation_service.py CHANGED
@@ -154,6 +154,17 @@ class ReconciliationService:
154
  ):
155
  return
156
 
 
 
 
 
 
 
 
 
 
 
 
157
  proposal = Proposal(
158
  workspace_id=workspace_id,
159
  knowledge_item_id=item.id,
@@ -171,6 +182,7 @@ class ReconciliationService:
171
  "summary": item.summary,
172
  "attributes": item.attributes,
173
  "confidence": item.confidence,
 
174
  },
175
  )
176
 
@@ -189,6 +201,17 @@ class ReconciliationService:
189
  ):
190
  return
191
 
 
 
 
 
 
 
 
 
 
 
 
192
  proposal = Proposal(
193
  workspace_id=workspace_id,
194
  knowledge_item_id=existing_item.id,
@@ -211,12 +234,15 @@ class ReconciliationService:
211
  "summary": new_item.summary,
212
  "attributes": new_item.attributes,
213
  "confidence": new_item.confidence,
 
214
  },
 
215
  "source_knowledge_item_id": str(new_item.id),
216
  },
217
  )
218
 
219
  self.db.add(proposal)
 
220
  @staticmethod
221
  def _normalize(value: str | None) -> str:
222
  if not value:
@@ -224,4 +250,4 @@ class ReconciliationService:
224
 
225
  words = value.lower().strip().split()
226
 
227
- return " ".join(sorted(words))
 
154
  ):
155
  return
156
 
157
+ evidence_list = [
158
+ {
159
+ "quote": ev.quote,
160
+ "page_number": ev.page_number,
161
+ "section": ev.section,
162
+ "confidence": ev.confidence,
163
+ "source_type": ev.source_type,
164
+ }
165
+ for ev in (item.evidence or [])
166
+ ]
167
+
168
  proposal = Proposal(
169
  workspace_id=workspace_id,
170
  knowledge_item_id=item.id,
 
182
  "summary": item.summary,
183
  "attributes": item.attributes,
184
  "confidence": item.confidence,
185
+ "evidence": evidence_list,
186
  },
187
  )
188
 
 
201
  ):
202
  return
203
 
204
+ evidence_list = [
205
+ {
206
+ "quote": ev.quote,
207
+ "page_number": ev.page_number,
208
+ "section": ev.section,
209
+ "confidence": ev.confidence,
210
+ "source_type": ev.source_type,
211
+ }
212
+ for ev in (new_item.evidence or [])
213
+ ]
214
+
215
  proposal = Proposal(
216
  workspace_id=workspace_id,
217
  knowledge_item_id=existing_item.id,
 
234
  "summary": new_item.summary,
235
  "attributes": new_item.attributes,
236
  "confidence": new_item.confidence,
237
+ "evidence": evidence_list,
238
  },
239
+ "evidence": evidence_list,
240
  "source_knowledge_item_id": str(new_item.id),
241
  },
242
  )
243
 
244
  self.db.add(proposal)
245
+
246
  @staticmethod
247
  def _normalize(value: str | None) -> str:
248
  if not value:
 
250
 
251
  words = value.lower().strip().split()
252
 
253
+ return " ".join(words)
backend/app/workflow/graph.py CHANGED
@@ -1,4 +1,4 @@
1
- ο»Ώfrom __future__ import annotations
2
 
3
  from sqlalchemy.orm import Session
4
  from langgraph.graph import END, START, StateGraph
@@ -226,10 +226,10 @@ def build_workflow(
226
  "complete",
227
  )
228
 
229
- # After human approval/resume, continue to completion.
230
  graph.add_edge(
231
  "human_review",
232
- "complete",
233
  )
234
 
235
  graph.add_edge(
 
1
+ from __future__ import annotations
2
 
3
  from sqlalchemy.orm import Session
4
  from langgraph.graph import END, START, StateGraph
 
226
  "complete",
227
  )
228
 
229
+ # After human approval/resume, route through link before completion.
230
  graph.add_edge(
231
  "human_review",
232
+ "link",
233
  )
234
 
235
  graph.add_edge(
backend/app/workflow/nodes/complete.py CHANGED
@@ -19,10 +19,11 @@ def complete(
19
  """
20
  from sqlalchemy.orm import Session
21
  from app.database.database import SessionLocal
22
- from app.models.proposal import Proposal, ProposalStatus
23
  from app.models.knowledge_item import KnowledgeItem, KnowledgeStatus
24
  from app.models.commit import Commit
25
  from datetime import datetime, timezone
 
26
  import logging
27
 
28
  _log = logging.getLogger(__name__)
@@ -80,14 +81,11 @@ def complete(
80
  tracker.end_stage("complete")
81
  return state
82
 
83
- proposals = (
84
- db.query(Proposal)
85
- .filter(
86
- Proposal.workspace_id == state.workspace_id,
87
- Proposal.knowledge_item_id.in_(ki_ids),
88
- Proposal.status == ProposalStatus.PENDING,
89
- )
90
- .all()
91
  )
92
 
93
  _log.info(
@@ -102,6 +100,30 @@ def complete(
102
  proposal.status = ProposalStatus.APPROVED
103
  proposal.reviewed_at = now
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  commit = Commit(
106
  workspace_id=proposal.workspace_id,
107
  proposal_id=proposal.id,
@@ -111,7 +133,7 @@ def complete(
111
  )
112
  db.add(commit)
113
 
114
- # Promote knowledge items to ACTIVE
115
  for ki in knowledge_items:
116
  if ki.status == KnowledgeStatus.PENDING:
117
  ki.status = KnowledgeStatus.ACTIVE
 
19
  """
20
  from sqlalchemy.orm import Session
21
  from app.database.database import SessionLocal
22
+ from app.models.proposal import Proposal, ProposalStatus, ProposalType
23
  from app.models.knowledge_item import KnowledgeItem, KnowledgeStatus
24
  from app.models.commit import Commit
25
  from datetime import datetime, timezone
26
+ import uuid
27
  import logging
28
 
29
  _log = logging.getLogger(__name__)
 
81
  tracker.end_stage("complete")
82
  return state
83
 
84
+ from app.services.proposal_review_service import ProposalReviewService
85
+ review_service = ProposalReviewService(db)
86
+ proposals = review_service.get_pending_for_document_version(
87
+ workspace_id=state.workspace_id,
88
+ document_version_id=state.document_version_id,
 
 
 
89
  )
90
 
91
  _log.info(
 
100
  proposal.status = ProposalStatus.APPROVED
101
  proposal.reviewed_at = now
102
 
103
+ if proposal.proposal_type == ProposalType.UPDATE and proposal.knowledge_item_id:
104
+ target_item = db.query(KnowledgeItem).filter(KnowledgeItem.id == proposal.knowledge_item_id).first()
105
+ if target_item:
106
+ changes = proposal.proposed_changes or {}
107
+ proposed = changes.get("proposed", {})
108
+ if "value" in proposed:
109
+ target_item.value = proposed["value"]
110
+ if "summary" in proposed:
111
+ target_item.summary = proposed["summary"]
112
+ if "attributes" in proposed:
113
+ target_item.attributes = proposed["attributes"]
114
+ if "confidence" in proposed:
115
+ target_item.confidence = proposed["confidence"]
116
+ target_item.status = KnowledgeStatus.ACTIVE
117
+
118
+ source_id = changes.get("source_knowledge_item_id")
119
+ if source_id:
120
+ try:
121
+ src_item = db.query(KnowledgeItem).filter(KnowledgeItem.id == uuid.UUID(str(source_id))).first()
122
+ if src_item:
123
+ src_item.status = KnowledgeStatus.SUPERSEDED
124
+ except Exception:
125
+ pass
126
+
127
  commit = Commit(
128
  workspace_id=proposal.workspace_id,
129
  proposal_id=proposal.id,
 
133
  )
134
  db.add(commit)
135
 
136
+ # Promote remaining PENDING knowledge items for this doc version to ACTIVE
137
  for ki in knowledge_items:
138
  if ki.status == KnowledgeStatus.PENDING:
139
  ki.status = KnowledgeStatus.ACTIVE
backend/app/workflow/nodes/validate.py CHANGED
@@ -35,13 +35,26 @@ def validate(
35
  new_item_ids = {str(item.id) for item in new_items}
36
 
37
  if not new_item_ids:
38
- state.validation_results = []
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  state.metadata["validation_summary"] = {
40
- "status": "PASS",
41
- "rules_evaluated": 0,
42
  "proposals_evaluated": 0,
43
- "failures": 0,
44
- "warnings": 0,
45
  }
46
  tracker.end_stage("validation")
47
  return state
 
35
  new_item_ids = {str(item.id) for item in new_items}
36
 
37
  if not new_item_ids:
38
+ error_msg = state.metadata.get("extraction_error") or "No knowledge items were extracted from this document."
39
+ has_err = bool(state.metadata.get("extraction_error"))
40
+ state.validation_results = [
41
+ {
42
+ "status": "FAIL" if has_err else "WARNING",
43
+ "severity": "HIGH",
44
+ "rule_id": None,
45
+ "rule_name": "Extraction Verification",
46
+ "rule_type": None,
47
+ "operator": None,
48
+ "message": error_msg,
49
+ "proposal_ids": [],
50
+ }
51
+ ]
52
  state.metadata["validation_summary"] = {
53
+ "status": "FAIL" if has_err else "WARNING",
54
+ "rules_evaluated": 1,
55
  "proposals_evaluated": 0,
56
+ "failures": 1 if has_err else 0,
57
+ "warnings": 0 if has_err else 1,
58
  }
59
  tracker.end_stage("validation")
60
  return state
backend/tests/test_mcp_e2e.py CHANGED
@@ -77,6 +77,7 @@ def test_mcp_e2e():
77
  print(" PASS")
78
 
79
  # 5. Get workflow status (use an existing workflow if available)
 
80
  if result:
81
  workflow_id = result[0]["workflow_id"]
82
  print(f"\n5. get_workflow_status ({workflow_id[:8]}...)")
 
77
  print(" PASS")
78
 
79
  # 5. Get workflow status (use an existing workflow if available)
80
+ workflow_id = None
81
  if result:
82
  workflow_id = result[0]["workflow_id"]
83
  print(f"\n5. get_workflow_status ({workflow_id[:8]}...)")
docs/{architecture-diagram.md β†’ DocWeave_architecture_diagram.md} RENAMED
File without changes
docs/one-pager.md DELETED
@@ -1,40 +0,0 @@
1
- # DocWeave β€” One Page
2
-
3
- ## What I Built
4
-
5
- DocWeave is an agentic document intelligence platform that transforms unstructured documents into a governed knowledge register. It processes PDFs through an AI pipeline (extract, chunk, embed, extract knowledge, reconcile, validate, decide) and either auto-commits verified knowledge or pauses for human review β€” depending on configurable validation rules.
6
-
7
- Think of it as "GitHub for documents": every knowledge change is proposed, validated against rules, reviewed if needed, and committed with full audit trail.
8
-
9
- ## Who It's For
10
-
11
- Teams that manage large document corpora and need structured, searchable, auditable knowledge β€” compliance officers, research groups, clinical teams, legal departments. Anyone who's ever said "I know we read this somewhere but can't find which document said it."
12
-
13
- ## Results
14
-
15
- - **End-to-end processing**: Upload a PDF, get structured knowledge items (ENTITY, CLAIM, METHOD, METRIC, OBSERVATION) with verbatim evidence quotes and confidence scores in 20-45 seconds.
16
- - **Zero-loss batch extraction**: Documents of any size are batched and processed sequentially β€” no data is truncated or lost.
17
- - **Human-in-the-loop**: 4 configurable validation operators (min_confidence, required_evidence, allowed_proposal_types, topic_relevance) determine auto-commit vs. human review.
18
- - **Crash resilience**: LangGraph checkpointing means workflows survive server restarts. Graceful shutdown waits for active work; auto-resume picks up orphaned workflows.
19
- - **MCP integration**: 15-tool Model Context Protocol server lets AI agents drive the full lifecycle programmatically.
20
- - **Deployed**: Frontend on Vercel, backend on HuggingFace Spaces (Docker), database on Neon PostgreSQL (Singapore).
21
-
22
- ## Key Trade-Offs
23
-
24
- | Decision | Why |
25
- |----------|-----|
26
- | **Background threads (not Celery)** | Simpler deployment, no Redis/broker needed. Trade-off: limited to single-server horizontal scaling. Acceptable for a demo/small team tool. |
27
- | **Groq free tier LLM** | Zero cost, fast inference. Trade-off: strict request size limits on free org tier β€” knowledge extraction requires small batches. A paid tier or self-hosted model removes this. |
28
- | **Ephemeral file storage on HF** | Files don't persist across container restarts. Trade-off: retry on old docs fails. Acceptable because production would use S3/GCS. Demo works for freshly uploaded docs. |
29
- | **sentence-transformers local embeddings** | No external API dependency, runs on CPU. Trade-off: ~300MB memory footprint, can't run on 512MB servers (Render free tier). Works fine on HF Spaces (16GB RAM). |
30
- | **PostgreSQL for everything (vectors + relational)** | Single database, pgvector handles vector search. Trade-off: not as fast as dedicated vector DBs (Pinecone, Weaviate) at scale. Perfectly adequate for <100K documents. |
31
- | **Native JSON mode (not tool calling)** | More reliable structured output from Groq/Qwen/Llama models. Trade-off: less strict schema enforcement than OpenAI function calling. Mitigated by robust JSON parsing with fallbacks. |
32
-
33
- ## What I'd Add With More Time
34
-
35
- - Persistent cloud storage (S3) for uploaded documents
36
- - WebSocket-based real-time progress (currently polls)
37
- - Multi-user collaboration with role-based access
38
- - Knowledge graph visualization (entity relationship map)
39
- - Automatic conflict resolution using LLM reasoning
40
- - PDF annotation overlay showing where each knowledge item was extracted from