feat(hierarchy): supporto metadati strutturati e gerarchici su Qdrant (parent_id, level, topic, links)
This commit is contained in:
+68
-5
@@ -207,6 +207,23 @@ export default function qmemExtension(pi: ExtensionAPI) {
|
|||||||
),
|
),
|
||||||
supersedes_id: Type.Optional(Type.String({ description: "UUID del record da supersedere (correzione): il nuovo record diventa la versione attiva, il vecchio resta in archivio marcato superseded." })),
|
supersedes_id: Type.Optional(Type.String({ description: "UUID del record da supersedere (correzione): il nuovo record diventa la versione attiva, il vecchio resta in archivio marcato superseded." })),
|
||||||
supersede_reason: Type.Optional(Type.String({ description: "Motivo della correzione (visibile in audit e sul vecchio record)." })),
|
supersede_reason: Type.Optional(Type.String({ description: "Motivo della correzione (visibile in audit e sul vecchio record)." })),
|
||||||
|
parent_id: Type.Optional(Type.String({ description: "UUID del record genitore per organizzazione gerarchica/subtopic." })),
|
||||||
|
level: Type.Optional(
|
||||||
|
Type.Union([Type.Literal("L1_ROOT"), Type.Literal("L2_SUBTOPIC"), Type.Literal("L3_DETAIL")], {
|
||||||
|
description: "Livello gerarchico (L1_ROOT = indice macro-topic, L2_SUBTOPIC = dettaglio specialistico, L3_DETAIL).",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
topic: Type.Optional(Type.String({ description: "Topic ID gerarchico (es. ALFA-ROMEO-GT-1300-JUNIOR/SPECS)." })),
|
||||||
|
links: Type.Optional(
|
||||||
|
Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
target_id: Type.String({ description: "UUID del record target collegato." }),
|
||||||
|
predicate: Type.Optional(Type.String({ description: "Tipo di relazione (parent_of, part_of, relates_to, supersedes...)." })),
|
||||||
|
weight: Type.Optional(Type.Number({ description: "Peso della relazione (default: 1.0)." })),
|
||||||
|
}),
|
||||||
|
{ description: "Collegamenti relazionali espliciti verso altri record." },
|
||||||
|
),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
@@ -245,6 +262,10 @@ export default function qmemExtension(pi: ExtensionAPI) {
|
|||||||
expires_at: p.expires_at,
|
expires_at: p.expires_at,
|
||||||
supersedes_id: p.supersedes_id,
|
supersedes_id: p.supersedes_id,
|
||||||
supersede_reason: p.supersede_reason,
|
supersede_reason: p.supersede_reason,
|
||||||
|
parent_id: p.parent_id,
|
||||||
|
level: p.level,
|
||||||
|
topic: p.topic,
|
||||||
|
links: p.links,
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
idemKey,
|
idemKey,
|
||||||
@@ -261,9 +282,10 @@ export default function qmemExtension(pi: ExtensionAPI) {
|
|||||||
dupes.map((d: any) => ` - ${d.memory_id} (score ${d.score}): ${String(d.text).slice(0, 100)}`).join("\n") +
|
dupes.map((d: any) => ` - ${d.memory_id} (score ${d.score}): ${String(d.text).slice(0, 100)}`).join("\n") +
|
||||||
"\nValuta se il nuovo record è davvero necessario o se conviene qmem_correct sul duplicato."
|
"\nValuta se il nuovo record è davvero necessario o se conviene qmem_correct sul duplicato."
|
||||||
: "";
|
: "";
|
||||||
|
const extra = `${p.level ? `, level ${p.level}` : ""}${p.topic ? `, topic ${p.topic}` : ""}${p.parent_id ? ` — parent ${p.parent_id}` : ""}`;
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Memoria salvata: ${data.memory_id} (${p.kind ?? "fact"}, scope ${p.scope ?? "agent"})${p.supersedes_id ? ` — supersede ${p.supersedes_id}` : ""}${dupWarning}` }],
|
content: [{ type: "text", text: `Memoria salvata: ${data.memory_id} (${p.kind ?? "fact"}, scope ${p.scope ?? "agent"}${extra})${p.supersedes_id ? ` — supersede ${p.supersedes_id}` : ""}${dupWarning}` }],
|
||||||
details: { memory_id: data.memory_id, created_at: data.created_at, duplicates: dupes.length },
|
details: { memory_id: data.memory_id, created_at: data.created_at, duplicates: dupes.length, parent_id: p.parent_id, level: p.level, topic: p.topic },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -318,6 +340,13 @@ export default function qmemExtension(pi: ExtensionAPI) {
|
|||||||
"non come similarità. min_score resta applicato al ramo vettoriale (anti-rumore).",
|
"non come similarità. min_score resta applicato al ramo vettoriale (anti-rumore).",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
parent_id: Type.Optional(Type.String({ description: "Filtra per UUID del record genitore." })),
|
||||||
|
level: Type.Optional(
|
||||||
|
Type.Union([Type.Literal("L1_ROOT"), Type.Literal("L2_SUBTOPIC"), Type.Literal("L3_DETAIL")], {
|
||||||
|
description: "Filtra per livello gerarchico.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
topic: Type.Optional(Type.String({ description: "Filtra per topic esatto." })),
|
||||||
}),
|
}),
|
||||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
@@ -342,6 +371,9 @@ export default function qmemExtension(pi: ExtensionAPI) {
|
|||||||
min_score: p.min_score ?? 0.45,
|
min_score: p.min_score ?? 0.45,
|
||||||
top_k: p.top_k ?? 5,
|
top_k: p.top_k ?? 5,
|
||||||
hybrid: p.hybrid ?? false,
|
hybrid: p.hybrid ?? false,
|
||||||
|
parent_id: p.parent_id,
|
||||||
|
level: p.level,
|
||||||
|
topic: p.topic,
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
@@ -364,8 +396,13 @@ export default function qmemExtension(pi: ExtensionAPI) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const lines = results.map(
|
const lines = results.map(
|
||||||
(r: any, i: number) =>
|
(r: any, i: number) => {
|
||||||
`${i + 1}. [${r.kind}/${r.scope} score=${r.score}${r.score < 0.6 ? " ⚠️" : ""}${r.confidence ? ` conf=${r.confidence}` : ""}] ${r.text}\n (id: ${r.memory_id}, agente: ${r.agent_id ?? "?"}, creato: ${r.created_at ?? "?"}${r.source ? `, fonte: ${r.source}` : ""}${r.supersedes_id ? `, supersede ${r.supersedes_id}` : ""}${r.superseded_by ? `, ⚠️ superseduto da ${r.superseded_by}` : ""})`,
|
const lvl = r.level ? ` [${r.level}]` : "";
|
||||||
|
const top = r.topic ? ` (${r.topic})` : "";
|
||||||
|
const parent = r.parent_id ? `, parent: ${r.parent_id}` : "";
|
||||||
|
const links = r.links && r.links.length > 0 ? `, links: ${r.links.length}` : "";
|
||||||
|
return `${i + 1}. [${r.kind}/${r.scope}${lvl}${top} score=${r.score}${r.score < 0.6 ? " ⚠️" : ""}${r.confidence ? ` conf=${r.confidence}` : ""}] ${r.text}\n (id: ${r.memory_id}${parent}${links}, agente: ${r.agent_id ?? "?"}, creato: ${r.created_at ?? "?"}${r.source ? `, fonte: ${r.source}` : ""}${r.supersedes_id ? `, supersede ${r.supersedes_id}` : ""}${r.superseded_by ? `, ⚠️ superseduto da ${r.superseded_by}` : ""})`;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: lines.join("\n") }],
|
content: [{ type: "text", text: lines.join("\n") }],
|
||||||
@@ -410,6 +447,23 @@ export default function qmemExtension(pi: ExtensionAPI) {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
agent_id: Type.Optional(Type.String({ description: "Nome dell'agente che corregge (solo provenienza)." })),
|
agent_id: Type.Optional(Type.String({ description: "Nome dell'agente che corregge (solo provenienza)." })),
|
||||||
|
parent_id: Type.Optional(Type.String({ description: "UUID del record genitore (default: eredita dal record superseduto se presente)." })),
|
||||||
|
level: Type.Optional(
|
||||||
|
Type.Union([Type.Literal("L1_ROOT"), Type.Literal("L2_SUBTOPIC"), Type.Literal("L3_DETAIL")], {
|
||||||
|
description: "Livello gerarchico del nuovo record.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
topic: Type.Optional(Type.String({ description: "Topic ID gerarchico del nuovo record." })),
|
||||||
|
links: Type.Optional(
|
||||||
|
Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
target_id: Type.String(),
|
||||||
|
predicate: Type.Optional(Type.String()),
|
||||||
|
weight: Type.Optional(Type.Number()),
|
||||||
|
}),
|
||||||
|
{ description: "Collegamenti relazionali espliciti." },
|
||||||
|
),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
@@ -496,6 +550,10 @@ export default function qmemExtension(pi: ExtensionAPI) {
|
|||||||
source: "qmem_correct",
|
source: "qmem_correct",
|
||||||
supersedes_id: memoryId,
|
supersedes_id: memoryId,
|
||||||
supersede_reason: p.reason,
|
supersede_reason: p.reason,
|
||||||
|
parent_id: p.parent_id ?? orig.parent_id,
|
||||||
|
level: p.level ?? orig.level,
|
||||||
|
topic: p.topic ?? orig.topic,
|
||||||
|
links: p.links ?? orig.links,
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
idemKey,
|
idemKey,
|
||||||
@@ -610,10 +668,12 @@ export default function qmemExtension(pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
const r = data ?? {};
|
const r = data ?? {};
|
||||||
const meta = [
|
const meta = [
|
||||||
`[${r.kind ?? "?"}/${r.scope ?? "?"}${r.confidence ? ` conf=${r.confidence}` : ""}]`,
|
`[${r.kind ?? "?"}/${r.scope ?? "?"}${r.level ? ` ${r.level}` : ""}${r.topic ? ` (${r.topic})` : ""}${r.confidence ? ` conf=${r.confidence}` : ""}]`,
|
||||||
`project: ${r.project_id ?? "?"}`,
|
`project: ${r.project_id ?? "?"}`,
|
||||||
`agente: ${r.agent_id ?? "?"}, creato: ${r.created_at ?? "?"}${r.source ? `, fonte: ${r.source}` : ""}`,
|
`agente: ${r.agent_id ?? "?"}, creato: ${r.created_at ?? "?"}${r.source ? `, fonte: ${r.source}` : ""}`,
|
||||||
];
|
];
|
||||||
|
if (r.parent_id) meta.push(`parent: ${r.parent_id}`);
|
||||||
|
if (r.links && r.links.length > 0) meta.push(`links: ${r.links.length}`);
|
||||||
if (r.supersedes_id) meta.push(`supersede ${r.supersedes_id}`);
|
if (r.supersedes_id) meta.push(`supersede ${r.supersedes_id}`);
|
||||||
if (r.superseded_by) meta.push(`⚠️ SUPERSEDUTO da ${r.superseded_by}`);
|
if (r.superseded_by) meta.push(`⚠️ SUPERSEDUTO da ${r.superseded_by}`);
|
||||||
if (r.expires_at) meta.push(`scade: ${r.expires_at}`);
|
if (r.expires_at) meta.push(`scade: ${r.expires_at}`);
|
||||||
@@ -625,6 +685,9 @@ export default function qmemExtension(pi: ExtensionAPI) {
|
|||||||
kind: r.kind,
|
kind: r.kind,
|
||||||
scope: r.scope,
|
scope: r.scope,
|
||||||
project_id: r.project_id,
|
project_id: r.project_id,
|
||||||
|
parent_id: r.parent_id,
|
||||||
|
level: r.level,
|
||||||
|
topic: r.topic,
|
||||||
superseded_by: r.superseded_by ?? null,
|
superseded_by: r.superseded_by ?? null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+45
-5
@@ -66,7 +66,7 @@ GUARDRAIL_VERSION = "similarity-v1"
|
|||||||
# Versione del codice: hash del commit Git da cui è stato costruito il container
|
# Versione del codice: hash del commit Git da cui è stato costruito il container
|
||||||
# (iniettato come build arg nel Dockerfile: ARG GIT_COMMIT / ENV GIT_COMMIT)
|
# (iniettato come build arg nel Dockerfile: ARG GIT_COMMIT / ENV GIT_COMMIT)
|
||||||
GIT_COMMIT = os.environ.get("GIT_COMMIT", "unknown").strip()
|
GIT_COMMIT = os.environ.get("GIT_COMMIT", "unknown").strip()
|
||||||
GATEWAY_VERSION = os.environ.get("GATEWAY_VERSION", "2.7.0").strip()
|
GATEWAY_VERSION = os.environ.get("GATEWAY_VERSION", "2.8.0").strip()
|
||||||
|
|
||||||
# Metriche: push a VictoriaMetrics (stesso pattern dell'energy engine domotics)
|
# Metriche: push a VictoriaMetrics (stesso pattern dell'energy engine domotics)
|
||||||
VM_PUSH_URL = os.environ.get("VM_PUSH_URL", "http://host.docker.internal:8428/api/v1/import/prometheus")
|
VM_PUSH_URL = os.environ.get("VM_PUSH_URL", "http://host.docker.internal:8428/api/v1/import/prometheus")
|
||||||
@@ -110,7 +110,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
SPARSE_VECTOR_NAME: qm.SparseVectorParams(modifier=qm.Modifier.IDF),
|
SPARSE_VECTOR_NAME: qm.SparseVectorParams(modifier=qm.Modifier.IDF),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
for field in ("agent_id", "project_id", "scope", "kind", "supersedes_id", "superseded_by", "text_hash"):
|
for field in ("agent_id", "project_id", "scope", "kind", "supersedes_id", "superseded_by", "text_hash", "parent_id", "level", "topic"):
|
||||||
qdrant.create_payload_index(
|
qdrant.create_payload_index(
|
||||||
collection_name=COLLECTION,
|
collection_name=COLLECTION,
|
||||||
field_name=field,
|
field_name=field,
|
||||||
@@ -124,6 +124,16 @@ async def lifespan(_app: FastAPI):
|
|||||||
log.info("collection %s creata con indici (dense + sparse %s)", COLLECTION, SPARSE_VECTOR_NAME)
|
log.info("collection %s creata con indici (dense + sparse %s)", COLLECTION, SPARSE_VECTOR_NAME)
|
||||||
else:
|
else:
|
||||||
log.info("collection %s già esistente", COLLECTION)
|
log.info("collection %s già esistente", COLLECTION)
|
||||||
|
# Migrazione indici gerarchici: crea se mancanti
|
||||||
|
for field in ("parent_id", "level", "topic"):
|
||||||
|
try:
|
||||||
|
qdrant.create_payload_index(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
field_name=field,
|
||||||
|
field_schema=qm.PayloadSchemaType.KEYWORD,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
# Migrazione: aggiunge lo sparse vector se manca (collection pre-ibrida)
|
# Migrazione: aggiunge lo sparse vector se manca (collection pre-ibrida)
|
||||||
info = qdrant.get_collection(COLLECTION)
|
info = qdrant.get_collection(COLLECTION)
|
||||||
sparse_vectors = (info.config.params.sparse_vectors or {}) if info.config and info.config.params else {}
|
sparse_vectors = (info.config.params.sparse_vectors or {}) if info.config and info.config.params else {}
|
||||||
@@ -158,7 +168,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
_http = None
|
_http = None
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Memory Gateway", version="2.7.0", lifespan=lifespan)
|
app = FastAPI(title="Memory Gateway", version="2.8.0", lifespan=lifespan)
|
||||||
qdrant = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
|
qdrant = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
|
||||||
|
|
||||||
# Request ID: generato per richiesta, loggato nell'audit e restituito in header
|
# Request ID: generato per richiesta, loggato nell'audit e restituito in header
|
||||||
@@ -226,6 +236,12 @@ def _invalidate_meta() -> None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Modelli
|
# Modelli
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
class MemoryLink(BaseModel):
|
||||||
|
target_id: str = Field(..., description="UUID del record target collegato")
|
||||||
|
predicate: str = Field(default="part_of", max_length=64, description="Tipo di relazione: parent_of, part_of, relates_to, supersedes...")
|
||||||
|
weight: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
class MemoryIn(BaseModel):
|
class MemoryIn(BaseModel):
|
||||||
text: str = Field(min_length=1, max_length=MAX_TEXT_LEN)
|
text: str = Field(min_length=1, max_length=MAX_TEXT_LEN)
|
||||||
kind: Literal["decision", "fact", "episode", "preference"] = "fact"
|
kind: Literal["decision", "fact", "episode", "preference"] = "fact"
|
||||||
@@ -237,6 +253,10 @@ class MemoryIn(BaseModel):
|
|||||||
expires_at: Optional[str] = None # ISO 8601
|
expires_at: Optional[str] = None # ISO 8601
|
||||||
supersedes_id: Optional[str] = None
|
supersedes_id: Optional[str] = None
|
||||||
supersede_reason: Optional[str] = Field(default=None, max_length=512)
|
supersede_reason: Optional[str] = Field(default=None, max_length=512)
|
||||||
|
parent_id: Optional[str] = Field(default=None, description="UUID del record genitore per gerarchia/subtopic")
|
||||||
|
level: Optional[Literal["L1_ROOT", "L2_SUBTOPIC", "L3_DETAIL"]] = Field(default=None, description="Livello gerarchico del record")
|
||||||
|
topic: Optional[str] = Field(default=None, max_length=128, description="Topic gerarchico (es. ALFA-ROMEO-GT-1300-JUNIOR/SPECS)")
|
||||||
|
links: Optional[list[MemoryLink]] = Field(default=None, description="Collegamenti semantici e relazionali verso altri record")
|
||||||
|
|
||||||
@field_validator("expires_at")
|
@field_validator("expires_at")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -260,6 +280,9 @@ class SearchIn(BaseModel):
|
|||||||
min_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
min_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
||||||
top_k: int = Field(default=5, ge=1, le=20)
|
top_k: int = Field(default=5, ge=1, le=20)
|
||||||
hybrid: bool = Field(default=False, description="True = hybrid retrieval (BM25 + vettoriale, RRF). I punteggi risultanti sono RRF, non cosine.")
|
hybrid: bool = Field(default=False, description="True = hybrid retrieval (BM25 + vettoriale, RRF). I punteggi risultanti sono RRF, non cosine.")
|
||||||
|
parent_id: Optional[str] = Field(default=None, description="Filtra per UUID del record genitore")
|
||||||
|
level: Optional[Literal["L1_ROOT", "L2_SUBTOPIC", "L3_DETAIL"]] = Field(default=None, description="Filtra per livello gerarchico")
|
||||||
|
topic: Optional[str] = Field(default=None, description="Filtra per topic esatto")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -334,7 +357,7 @@ def _find_similar(text: str, vector: list[float], top_k: int = 3) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _decide_guardrail(text: str, vector: list[float]) -> dict:
|
def _decide_guardrail(text: str, vector: list[float], topic: Optional[str] = None, parent_id: Optional[str] = None) -> dict:
|
||||||
"""Applica il guardrail a 2 strati. Ritorna {decision, reason, matches}."""
|
"""Applica il guardrail a 2 strati. Ritorna {decision, reason, matches}."""
|
||||||
# Strato 1 — hash esatto (duplicato identico)
|
# Strato 1 — hash esatto (duplicato identico)
|
||||||
text_hash = _text_hash(text)
|
text_hash = _text_hash(text)
|
||||||
@@ -365,6 +388,9 @@ def _decide_guardrail(text: str, vector: list[float]) -> dict:
|
|||||||
|
|
||||||
top1 = matches[0]["score"]
|
top1 = matches[0]["score"]
|
||||||
if top1 >= GUARDRAIL_BLOCK_THRESHOLD:
|
if top1 >= GUARDRAIL_BLOCK_THRESHOLD:
|
||||||
|
# Se il nuovo record ha un topic o parent_id esplicito che lo differenzia, permetti con WARN
|
||||||
|
if (topic or parent_id) and any(m.get("memory_id") != parent_id for m in matches):
|
||||||
|
return {"decision": "WARN", "reason": "HIERARCHICAL_SUBTOPIC", "matches": matches}
|
||||||
return {"decision": "BLOCK", "reason": "KNOWN_SOLUTION", "matches": matches}
|
return {"decision": "BLOCK", "reason": "KNOWN_SOLUTION", "matches": matches}
|
||||||
if top1 >= GUARDRAIL_WARN_THRESHOLD:
|
if top1 >= GUARDRAIL_WARN_THRESHOLD:
|
||||||
return {"decision": "WARN", "reason": "MODERATE_SIMILARITY", "matches": matches}
|
return {"decision": "WARN", "reason": "MODERATE_SIMILARITY", "matches": matches}
|
||||||
@@ -512,7 +538,7 @@ async def add_memory(
|
|||||||
# Il supersede esplicito è una correzione intenzionale: bypassa il guardrail.
|
# Il supersede esplicito è una correzione intenzionale: bypassa il guardrail.
|
||||||
guardrail: Optional[dict] = None
|
guardrail: Optional[dict] = None
|
||||||
if GUARDRAIL_ENABLED and not body.supersedes_id:
|
if GUARDRAIL_ENABLED and not body.supersedes_id:
|
||||||
guardrail = _decide_guardrail(body.text, vector)
|
guardrail = _decide_guardrail(body.text, vector, topic=body.topic, parent_id=body.parent_id)
|
||||||
if guardrail["decision"] == "BLOCK":
|
if guardrail["decision"] == "BLOCK":
|
||||||
_audit(
|
_audit(
|
||||||
key,
|
key,
|
||||||
@@ -544,6 +570,10 @@ async def add_memory(
|
|||||||
"expires_at": _parse_ts(body.expires_at),
|
"expires_at": _parse_ts(body.expires_at),
|
||||||
"supersedes_id": superseded_id,
|
"supersedes_id": superseded_id,
|
||||||
"supersede_reason": body.supersede_reason,
|
"supersede_reason": body.supersede_reason,
|
||||||
|
"parent_id": body.parent_id,
|
||||||
|
"level": body.level,
|
||||||
|
"topic": body.topic,
|
||||||
|
"links": [link.model_dump() for link in body.links] if body.links else None,
|
||||||
"embedding_model": EMBED_MODEL,
|
"embedding_model": EMBED_MODEL,
|
||||||
"text_hash": _text_hash(body.text),
|
"text_hash": _text_hash(body.text),
|
||||||
}
|
}
|
||||||
@@ -600,6 +630,12 @@ async def search_memories(body: SearchIn, key: str = Depends(require_auth)) -> d
|
|||||||
must.append(qm.FieldCondition(key="project_id", match=qm.MatchValue(value=body.project_id)))
|
must.append(qm.FieldCondition(key="project_id", match=qm.MatchValue(value=body.project_id)))
|
||||||
if body.scope:
|
if body.scope:
|
||||||
must.append(qm.FieldCondition(key="scope", match=qm.MatchValue(value=body.scope)))
|
must.append(qm.FieldCondition(key="scope", match=qm.MatchValue(value=body.scope)))
|
||||||
|
if body.parent_id:
|
||||||
|
must.append(qm.FieldCondition(key="parent_id", match=qm.MatchValue(value=body.parent_id)))
|
||||||
|
if body.level:
|
||||||
|
must.append(qm.FieldCondition(key="level", match=qm.MatchValue(value=body.level)))
|
||||||
|
if body.topic:
|
||||||
|
must.append(qm.FieldCondition(key="topic", match=qm.MatchValue(value=body.topic)))
|
||||||
if not body.include_superseded:
|
if not body.include_superseded:
|
||||||
# default: esclude i record già corretti (superseded_by presente)
|
# default: esclude i record già corretti (superseded_by presente)
|
||||||
must.append(qm.IsEmptyCondition(is_empty=qm.PayloadField(key="superseded_by")))
|
must.append(qm.IsEmptyCondition(is_empty=qm.PayloadField(key="superseded_by")))
|
||||||
@@ -655,6 +691,10 @@ async def search_memories(body: SearchIn, key: str = Depends(require_auth)) -> d
|
|||||||
"supersedes_id": h.payload.get("supersedes_id"),
|
"supersedes_id": h.payload.get("supersedes_id"),
|
||||||
"superseded_by": h.payload.get("superseded_by"),
|
"superseded_by": h.payload.get("superseded_by"),
|
||||||
"supersede_reason": h.payload.get("supersede_reason"),
|
"supersede_reason": h.payload.get("supersede_reason"),
|
||||||
|
"parent_id": h.payload.get("parent_id"),
|
||||||
|
"level": h.payload.get("level"),
|
||||||
|
"topic": h.payload.get("topic"),
|
||||||
|
"links": h.payload.get("links"),
|
||||||
}
|
}
|
||||||
for h in hits
|
for h in hits
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -216,3 +216,97 @@ def test_status_espone_git_commit(client):
|
|||||||
assert "git_commit" in data
|
assert "git_commit" in data
|
||||||
assert "version" in data
|
assert "version" in data
|
||||||
assert "guardrail_version" in data
|
assert "guardrail_version" in data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Struttura Gerarchica e Relazionale
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_create_and_retrieve_hierarchical_record(client):
|
||||||
|
"""Crea un nodo Root L1 e un nodo Figlio L2 con links, parent_id, level, topic."""
|
||||||
|
# 1. Crea Root L1
|
||||||
|
r_root = client.post(
|
||||||
|
"/v1/memories",
|
||||||
|
json=make_record(
|
||||||
|
text="Master Topic Alfa Romeo",
|
||||||
|
level="L1_ROOT",
|
||||||
|
topic="ALFA-ROMEO/ROOT",
|
||||||
|
),
|
||||||
|
headers=auth_headers(),
|
||||||
|
)
|
||||||
|
assert r_root.status_code == 200
|
||||||
|
root_id = r_root.json()["memory_id"]
|
||||||
|
|
||||||
|
# 2. Crea Figlio L2 collegato
|
||||||
|
r_child = client.post(
|
||||||
|
"/v1/memories",
|
||||||
|
json=make_record(
|
||||||
|
text="Scheda Tecnica Bialbero 1.3",
|
||||||
|
parent_id=root_id,
|
||||||
|
level="L2_SUBTOPIC",
|
||||||
|
topic="ALFA-ROMEO/SPECS",
|
||||||
|
links=[{"target_id": root_id, "predicate": "part_of", "weight": 1.0}],
|
||||||
|
),
|
||||||
|
headers=auth_headers(),
|
||||||
|
)
|
||||||
|
assert r_child.status_code == 200
|
||||||
|
child_id = r_child.json()["memory_id"]
|
||||||
|
|
||||||
|
# 3. Recupera e verifica payload strutturato
|
||||||
|
g = client.get(f"/v1/memories/{child_id}", headers=auth_headers())
|
||||||
|
assert g.status_code == 200
|
||||||
|
data = g.json()
|
||||||
|
assert data["parent_id"] == root_id
|
||||||
|
assert data["level"] == "L2_SUBTOPIC"
|
||||||
|
assert data["topic"] == "ALFA-ROMEO/SPECS"
|
||||||
|
assert len(data["links"]) == 1
|
||||||
|
assert data["links"][0]["target_id"] == root_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_filters_hierarchical(client):
|
||||||
|
"""Filtra per parent_id, level e topic."""
|
||||||
|
r_root = client.post(
|
||||||
|
"/v1/memories",
|
||||||
|
json=make_record(text="Root doc", level="L1_ROOT", topic="TOPIC/ROOT"),
|
||||||
|
headers=auth_headers(),
|
||||||
|
)
|
||||||
|
root_id = r_root.json()["memory_id"]
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/v1/memories",
|
||||||
|
json=make_record(text="Child A", parent_id=root_id, level="L2_SUBTOPIC", topic="TOPIC/A"),
|
||||||
|
headers=auth_headers(),
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/v1/memories",
|
||||||
|
json=make_record(text="Child B", parent_id=root_id, level="L2_SUBTOPIC", topic="TOPIC/B"),
|
||||||
|
headers=auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cerca solo L1_ROOT
|
||||||
|
s1 = client.post(
|
||||||
|
"/v1/memories:search",
|
||||||
|
json={"query": "doc", "level": "L1_ROOT", "top_k": 5, "min_score": 0.0},
|
||||||
|
headers=auth_headers(),
|
||||||
|
)
|
||||||
|
assert s1.status_code == 200
|
||||||
|
assert len(s1.json()["results"]) == 1
|
||||||
|
assert s1.json()["results"][0]["level"] == "L1_ROOT"
|
||||||
|
|
||||||
|
# Cerca per parent_id
|
||||||
|
s2 = client.post(
|
||||||
|
"/v1/memories:search",
|
||||||
|
json={"query": "Child", "parent_id": root_id, "top_k": 5, "min_score": 0.0},
|
||||||
|
headers=auth_headers(),
|
||||||
|
)
|
||||||
|
assert s2.status_code == 200
|
||||||
|
assert len(s2.json()["results"]) == 2
|
||||||
|
|
||||||
|
# Cerca per topic specifico
|
||||||
|
s3 = client.post(
|
||||||
|
"/v1/memories:search",
|
||||||
|
json={"query": "Child", "topic": "TOPIC/A", "top_k": 5, "min_score": 0.0},
|
||||||
|
headers=auth_headers(),
|
||||||
|
)
|
||||||
|
assert s3.status_code == 200
|
||||||
|
assert len(s3.json()["results"]) == 1
|
||||||
|
assert s3.json()["results"][0]["topic"] == "TOPIC/A"
|
||||||
|
|||||||
+17
-2
@@ -11,10 +11,25 @@ Stack: estensione pi-qmem → gateway FastAPI (qmem.enne2.net) → Qdrant 1.19 +
|
|||||||
|
|
||||||
| Tool | Uso |
|
| Tool | Uso |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `qmem_search` | Ricerca semantica con filtri (kind, project_id, scope, top_k, include_superseded, min_score) |
|
| `qmem_search` | Ricerca semantica con filtri (kind, project_id, scope, top_k, include_superseded, min_score, parent_id, level, topic) |
|
||||||
| `qmem_store` | Salva un record (kind, project_id OBBLIGATORIO, scope, source, expires_at, supersedes_id) |
|
| `qmem_store` | Salva un record (kind, project_id OBBLIGATORIO, scope, source, expires_at, supersedes_id, parent_id, level, topic, links) |
|
||||||
| `qmem_correct` | Corregge una memoria falsa: nuovo record che supersede il vecchio |
|
| `qmem_correct` | Corregge una memoria falsa: nuovo record che supersede il vecchio |
|
||||||
| `qmem_meta` | Discovery: scope×kind, progetti, agenti, superseduti (per scegliere i filtri) |
|
| `qmem_meta` | Discovery: scope×kind, progetti, agenti, superseduti (per scegliere i filtri) |
|
||||||
|
| `qmem_get` | Recupero deterministico per UUID O(1) |
|
||||||
|
|
||||||
|
## Struttura Gerarchica e Relazioni (L1 Root + L2 Subtopics)
|
||||||
|
|
||||||
|
Quando si organizza un corpus di conoscenza strutturato o un dominio complesso:
|
||||||
|
|
||||||
|
1. **Nodi Foglia L2 (Dettaglio Specialistico):**
|
||||||
|
* Salva prima i record specifici di dettaglio con `level="L2_SUBTOPIC"`, `topic="MACRO-TOPIC/SUBTOPIC"` e `parent_id` (se già noto o collegabile).
|
||||||
|
* Esempio: `qmem_store(text="...", project_id="...", level="L2_SUBTOPIC", topic="ALFA-ROMEO-GT-1300/SPECS")`.
|
||||||
|
2. **Nodo Radice L1 (Master Topic Index):**
|
||||||
|
* Salva il record indice macro con `level="L1_ROOT"`, `topic="MACRO-TOPIC/ROOT"` e `links=[{"target_id": "<uuid-l2>", "predicate": "parent_of"}]`.
|
||||||
|
3. **Filtro nelle Ricerche:**
|
||||||
|
* Puoi filtrare in `qmem_search` per `parent_id="<uuid>"`, `level="L1_ROOT"`, oppure `topic="MACRO/SUB"`.
|
||||||
|
4. **Navigazione Deterministica:**
|
||||||
|
* Se atterri su un nodo L2, usa `parent_id` restituito nel payload per recuperare il Root con `qmem_get(memory_id)`.
|
||||||
|
|
||||||
## Punteggi di ricerca (BGE-M3, cosine)
|
## Punteggi di ricerca (BGE-M3, cosine)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user