Skip to content

Commit 62f587d

Browse files
committed
feat(graph): require evidence on manual concept creation, add evidence endpoint
Manually created concepts were born with zero evidence instances, making them ungrounded. Now evidence_text is required when creating concepts via API/CLI/MCP (except match_only mode and LLM extraction). Evidence is stored as an Instance node linked to the concept's synthetic source. Also adds: - POST /concepts/{id}/evidence endpoint for adding evidence to existing concepts - add_evidence action on the MCP concept tool - evidence_text parameter on the MCP graph tool (create concept + queue) - Missing @types dev deps that were causing pre-existing TS build failures
1 parent 51c5e78 commit 62f587d

11 files changed

Lines changed: 252 additions & 12 deletions

File tree

api/app/models/concepts.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ class ConceptCreate(BaseModel):
7474
CreationMethod.API,
7575
description="How this concept is being created"
7676
)
77+
evidence_text: Optional[str] = Field(
78+
None,
79+
description="Evidence/rationale for the concept (required for manual creation via API/CLI/MCP)",
80+
min_length=10,
81+
max_length=2000
82+
)
7783

7884
class Config:
7985
json_schema_extra = {
@@ -88,6 +94,17 @@ class Config:
8894
}
8995

9096

97+
class EvidenceCreate(BaseModel):
98+
"""Request to add evidence to an existing concept."""
99+
100+
evidence_text: str = Field(
101+
...,
102+
description="Evidence/rationale text supporting the concept",
103+
min_length=10,
104+
max_length=2000
105+
)
106+
107+
91108
class ConceptUpdate(BaseModel):
92109
"""Request to update an existing concept (partial update)."""
93110

api/app/routes/concepts.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
ConceptResponse,
1919
ConceptListResponse,
2020
CreationMethod,
21+
EvidenceCreate,
2122
)
2223
from ..models.auth import UserInDB
2324
from ..dependencies.auth import require_permission
@@ -277,3 +278,65 @@ async def delete_concept(
277278
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
278279
detail="Failed to delete concept"
279280
)
281+
282+
283+
@router.post(
284+
"/{concept_id}/evidence",
285+
status_code=status.HTTP_201_CREATED,
286+
summary="Add evidence to a concept"
287+
)
288+
async def add_evidence(
289+
concept_id: str,
290+
request: EvidenceCreate,
291+
current_user: UserInDB = Depends(require_permission("graph", "write"))
292+
):
293+
"""
294+
Add evidence text to an existing concept.
295+
296+
Creates an Instance node with the evidence text and links it
297+
to the concept via a synthetic source.
298+
299+
Requires `graph:write` permission.
300+
"""
301+
age_client = get_age_client()
302+
service = get_concept_service(age_client)
303+
304+
try:
305+
result = await service.add_evidence(
306+
concept_id=concept_id,
307+
evidence_text=request.evidence_text,
308+
user_id=str(current_user.id) if current_user else None
309+
)
310+
311+
log_audit_standalone(
312+
age_client=age_client,
313+
user_id=current_user.id if current_user else None,
314+
action=AuditAction.CREATE_CONCEPT.value,
315+
resource_type="evidence",
316+
resource_id=result["instance_id"],
317+
details={
318+
"concept_id": concept_id,
319+
"evidence_text": request.evidence_text[:100]
320+
},
321+
outcome="success"
322+
)
323+
324+
age_client.refresh_epoch()
325+
326+
return result
327+
except ValueError as e:
328+
if "not found" in str(e).lower():
329+
raise HTTPException(
330+
status_code=status.HTTP_404_NOT_FOUND,
331+
detail=str(e)
332+
)
333+
raise HTTPException(
334+
status_code=status.HTTP_400_BAD_REQUEST,
335+
detail=str(e)
336+
)
337+
except Exception as e:
338+
logger.error(f"Failed to add evidence to concept {concept_id}: {e}")
339+
raise HTTPException(
340+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
341+
detail="Failed to add evidence"
342+
)

api/app/services/concept_service.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ async def create_concept(
113113
f"No matching concept found for '{request.label}' in ontology '{request.ontology}'"
114114
)
115115

116+
# Require evidence for manual concept creation
117+
if request.creation_method != CreationMethod.LLM_EXTRACTION and not request.evidence_text:
118+
raise ValueError(
119+
"evidence_text is required when creating a concept manually. "
120+
"Provide the rationale or supporting evidence (min 10 characters)."
121+
)
122+
116123
# Create new concept
117124
concept_id = f"c_{uuid4().hex[:12]}"
118125

@@ -138,6 +145,14 @@ async def create_concept(
138145
# Link concept to source
139146
self.age_client.link_concept_to_source(concept_id, source_id)
140147

148+
# Create evidence instance if evidence_text provided
149+
if request.evidence_text:
150+
await self._create_evidence_instance(
151+
concept_id=concept_id,
152+
source_id=source_id,
153+
evidence_text=request.evidence_text
154+
)
155+
141156
# Store creation metadata (in concept properties)
142157
await self._set_concept_metadata(
143158
concept_id=concept_id,
@@ -215,6 +230,75 @@ async def _create_synthetic_source(
215230

216231
return source_id
217232

233+
async def _create_evidence_instance(
234+
self,
235+
concept_id: str,
236+
source_id: str,
237+
evidence_text: str
238+
) -> str:
239+
"""Create an Instance node and link it to a concept and source."""
240+
instance_id = f"i_{uuid4().hex[:12]}"
241+
self.age_client.create_instance_node(
242+
instance_id=instance_id,
243+
quote=evidence_text
244+
)
245+
self.age_client.link_instance_to_concept_and_source(
246+
instance_id=instance_id,
247+
concept_id=concept_id,
248+
source_id=source_id
249+
)
250+
return instance_id
251+
252+
async def add_evidence(
253+
self,
254+
concept_id: str,
255+
evidence_text: str,
256+
user_id: Optional[str] = None
257+
) -> Dict[str, Any]:
258+
"""Add evidence to an existing concept.
259+
260+
Creates a synthetic source, an Instance node with the evidence text,
261+
and links them: Concept -[EVIDENCED_BY]-> Instance -[FROM_SOURCE]-> Source.
262+
"""
263+
# Verify concept exists
264+
query = """
265+
MATCH (c:Concept {concept_id: $concept_id})
266+
RETURN c.label as label, c.ontology as ontology
267+
"""
268+
result = self.age_client._execute_cypher(
269+
query, params={"concept_id": concept_id}, fetch_one=True
270+
)
271+
if not result:
272+
raise ValueError(f"Concept {concept_id} not found")
273+
274+
label = result.get("label", "unknown")
275+
ontology = result.get("ontology", "unknown")
276+
277+
# Create synthetic source for this evidence
278+
source_id = await self._create_synthetic_source(
279+
ontology=ontology,
280+
concept_label=label,
281+
creation_method=CreationMethod.API,
282+
user_id=user_id
283+
)
284+
285+
# Link concept to the new source
286+
self.age_client.link_concept_to_source(concept_id, source_id)
287+
288+
# Create instance and link
289+
instance_id = await self._create_evidence_instance(
290+
concept_id=concept_id,
291+
source_id=source_id,
292+
evidence_text=evidence_text
293+
)
294+
295+
return {
296+
"concept_id": concept_id,
297+
"instance_id": instance_id,
298+
"source_id": source_id,
299+
"evidence_text": evidence_text
300+
}
301+
218302
async def _set_concept_metadata(
219303
self,
220304
concept_id: str,

cli/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,12 @@
7272
"table": "^6.8.1"
7373
},
7474
"devDependencies": {
75+
"@types/babel__generator": "^7.27.0",
76+
"@types/babel__template": "^7.4.4",
77+
"@types/istanbul-lib-report": "^3.0.3",
7578
"@types/jest": "^29.5.0",
7679
"@types/node": "^20.10.0",
80+
"@types/yargs-parser": "^21.0.3",
7781
"jest": "^29.7.0",
7882
"ts-jest": "^29.1.0",
7983
"typescript": "^5.3.0"

cli/src/api/client.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {
7272
EdgeListResponse,
7373
BatchCreateRequest,
7474
BatchCreateResponse,
75+
EvidenceResponse,
7576
} from '../types';
7677

7778
export class KnowledgeGraphClient {
@@ -733,6 +734,17 @@ export class KnowledgeGraphClient {
733734
return response.data;
734735
}
735736

737+
/**
738+
* Add evidence to an existing concept.
739+
*/
740+
async addEvidence(conceptId: string, evidenceText: string): Promise<EvidenceResponse> {
741+
const response = await this.client.post(
742+
`/concepts/${encodeURIComponent(conceptId)}/evidence`,
743+
{ evidence_text: evidenceText }
744+
);
745+
return response.data;
746+
}
747+
736748
/**
737749
* Get a concept by ID.
738750
*/

cli/src/mcp-server.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -442,13 +442,17 @@ For multi-step workflows (search → connect → expand → filter), compose the
442442
properties: {
443443
action: {
444444
type: 'string',
445-
enum: ['details', 'related', 'connect'],
446-
description: 'Operation: "details" (get ALL evidence), "related" (explore neighborhood), "connect" (find paths)',
445+
enum: ['details', 'related', 'connect', 'add_evidence'],
446+
description: 'Operation: "details" (get ALL evidence), "related" (explore neighborhood), "connect" (find paths), "add_evidence" (attach evidence text to a concept)',
447447
},
448-
// For details and related
448+
// For details, related, and add_evidence
449449
concept_id: {
450450
type: 'string',
451-
description: 'Concept ID (required for details, related)',
451+
description: 'Concept ID (required for details, related, add_evidence)',
452+
},
453+
evidence_text: {
454+
type: 'string',
455+
description: 'Evidence/rationale text to attach to a concept (required for add_evidence, min 10 chars)',
452456
},
453457
include_grounding: {
454458
type: 'boolean',
@@ -1064,6 +1068,7 @@ Queue executes sequentially, continues past errors by default (set continue_on_e
10641068
description: { type: 'string' },
10651069
search_terms: { type: 'array', items: { type: 'string' } },
10661070
matching_mode: { type: 'string', enum: ['auto', 'force_create', 'match_only'] },
1071+
evidence_text: { type: 'string' },
10671072
concept_id: { type: 'string' },
10681073
from_concept_id: { type: 'string' },
10691074
to_concept_id: { type: 'string' },
@@ -1109,6 +1114,10 @@ Queue executes sequentially, continues past errors by default (set continue_on_e
11091114
description: 'How to handle similar existing concepts (default: auto)',
11101115
default: 'auto',
11111116
},
1117+
evidence_text: {
1118+
type: 'string',
1119+
description: 'Evidence/rationale for the concept (required for create concept, min 10 chars). Stored as an Instance node.',
1120+
},
11121121
// Edge fields
11131122
from_concept_id: {
11141123
type: 'string',
@@ -1706,6 +1715,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
17061715
}
17071716
}
17081717

1718+
case 'add_evidence': {
1719+
const conceptId = toolArgs.concept_id as string;
1720+
const evidenceText = toolArgs.evidence_text as string;
1721+
1722+
if (!conceptId) {
1723+
throw new Error('concept_id is required for add_evidence');
1724+
}
1725+
if (!evidenceText || evidenceText.length < 10) {
1726+
throw new Error('evidence_text is required and must be at least 10 characters');
1727+
}
1728+
1729+
const result = await client.addEvidence(conceptId, evidenceText);
1730+
1731+
return {
1732+
content: [{ type: 'text', text: `Evidence added to concept ${result.concept_id}\n\nInstance: ${result.instance_id}\nSource: ${result.source_id}\nText: ${result.evidence_text}` }],
1733+
};
1734+
}
1735+
17091736
default:
17101737
throw new Error(`Unknown concept action: ${action}`);
17111738
}
@@ -2614,12 +2641,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
26142641
switch (action) {
26152642
case 'create': {
26162643
if (entity === 'concept') {
2644+
const evidenceText = toolArgs.evidence_text as string | undefined;
2645+
const matchingMode = toolArgs.matching_mode as string | undefined;
2646+
// Require evidence_text unless match_only (linking to existing concept)
2647+
if (matchingMode !== 'match_only' && (!evidenceText || evidenceText.length < 10)) {
2648+
throw new Error('evidence_text is required when creating a concept (min 10 characters). Provide the rationale or supporting evidence for why this concept exists.');
2649+
}
26172650
const result = await executor.createConcept({
26182651
label: toolArgs.label as string,
26192652
ontology: toolArgs.ontology as string,
26202653
description: toolArgs.description as string | undefined,
26212654
search_terms: toolArgs.search_terms as string[] | undefined,
2622-
matching_mode: toolArgs.matching_mode as 'auto' | 'force_create' | 'match_only' | undefined,
2655+
matching_mode: matchingMode as 'auto' | 'force_create' | 'match_only' | undefined,
2656+
evidence_text: evidenceText,
26232657
});
26242658
if (!result.success) throw new Error(result.error);
26252659
return {

cli/src/mcp/graph-operations.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export interface CreateConceptParams {
9696
description?: string;
9797
search_terms?: string[];
9898
matching_mode?: MatchingMode;
99+
evidence_text?: string;
99100
}
100101

101102
/**
@@ -191,6 +192,7 @@ export interface QueueOperation {
191192
description?: string;
192193
search_terms?: string[];
193194
matching_mode?: MatchingMode;
195+
evidence_text?: string;
194196
concept_id?: string;
195197
cascade?: boolean;
196198
label_contains?: string;
@@ -354,6 +356,7 @@ export class GraphOperationExecutor {
354356
search_terms: params.search_terms,
355357
matching_mode: params.matching_mode || 'auto',
356358
creation_method: 'mcp',
359+
evidence_text: params.evidence_text,
357360
});
358361

359362
return { success: true, data: result };
@@ -660,6 +663,7 @@ export class GraphOperationExecutor {
660663
description: op.description,
661664
search_terms: op.search_terms,
662665
matching_mode: op.matching_mode,
666+
evidence_text: op.evidence_text,
663667
});
664668
if (!result.success) {
665669
throw new Error(result.error);

cli/src/types/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,24 @@ export interface ConceptCreate {
10481048
search_terms?: string[];
10491049
matching_mode?: MatchingMode;
10501050
creation_method?: CreationMethod;
1051+
evidence_text?: string;
1052+
}
1053+
1054+
/**
1055+
* Request to add evidence to an existing concept.
1056+
*/
1057+
export interface EvidenceCreate {
1058+
evidence_text: string;
1059+
}
1060+
1061+
/**
1062+
* Response from adding evidence to a concept.
1063+
*/
1064+
export interface EvidenceResponse {
1065+
concept_id: string;
1066+
instance_id: string;
1067+
source_id: string;
1068+
evidence_text: string;
10511069
}
10521070

10531071
/**

0 commit comments

Comments
 (0)