mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
feat(api,control-plane): recall results carry the attachments behind each fact (#4277)
* feat(api,control-plane): recall results carry the attachments behind each fact A recall result reported no attachments, so an agent that recalled a fact derived from a screenshot had no way to show it. The only handle available was `include.chunks`, and going through the chunk is wrong: a chunk lists every attachment its text references, so a fact drawn from the prose beside a screenshot would be shown that screenshot as its evidence. Recall now returns `attachments[]` on each result, resolved from the per-fact edge the extractor recorded at retain time — the same edge the memory read endpoints already return. It is unconditional rather than another `include` flag: the ids live on `memory_units.attachment_ids`, so a bank that has retained no attachments pays one indexed read that resolves nothing. The Recall Analyzer renders them beneath each result, and drops the score breakdown row in favour of entity and tag chips (the shared facet chips) plus the occurred/mentioned timestamps — the per-signal scores are a retrieval debugging concern and are already in the Trace tab. Scores render at four significant digits with the exact value on hover; fixed decimals would collapse 0.001125 and 0.001004 to the same "0.001", which is why they were unrounded before. Entities are included by default because that flag gates the entity names on each result, not just the observations block. * chore: regenerate the docs-skill OpenAPI reference
This commit is contained in:
@@ -516,6 +516,14 @@ class RecallResult(BaseModel):
|
||||
None # IDs of source facts (observation type only, when source_facts is enabled)
|
||||
)
|
||||
scores: RecallScores | None = None # Per-stage recall scores (final/reranker/semantic/text)
|
||||
attachments: list["ChunkAttachment"] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Attachments this fact was drawn from, as recorded per fact at extraction time — the "
|
||||
"same edge the memory read endpoints return, not everything its chunk happened to "
|
||||
"carry. A fact stated in prose reports none. Omitted when there are none."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class EntityObservationResponse(BaseModel):
|
||||
@@ -909,7 +917,7 @@ def chunk_attachments_of(
|
||||
return list(seen.values()) or None
|
||||
|
||||
|
||||
def _attachment_payload(bank_id: str, record: "StoredAttachment") -> dict[str, Any]:
|
||||
def _attachment_model(bank_id: str, record: "StoredAttachment") -> ChunkAttachment:
|
||||
"""One attachment, in the shape every read surface returns."""
|
||||
return ChunkAttachment(
|
||||
id=record.short_id,
|
||||
@@ -919,7 +927,12 @@ def _attachment_payload(bank_id: str, record: "StoredAttachment") -> dict[str, A
|
||||
byte_size=record.byte_size,
|
||||
filename=record.filename,
|
||||
url=bank_attachment_url(bank_id, record.short_id),
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
def _attachment_payload(bank_id: str, record: "StoredAttachment") -> dict[str, Any]:
|
||||
"""The same attachment as a plain dict, for the endpoints that return one."""
|
||||
return _attachment_model(bank_id, record).model_dump()
|
||||
|
||||
|
||||
async def _attach_to_memories(
|
||||
@@ -950,6 +963,36 @@ async def _attach_to_memories(
|
||||
item["attachments"] = [_attachment_payload(bank_id, record) for record in records]
|
||||
|
||||
|
||||
async def _attach_to_recall_results(
|
||||
memory_app: "MemoryEngine",
|
||||
bank_id: str,
|
||||
results: "list[RecallResult]",
|
||||
request_context: RequestContext,
|
||||
) -> None:
|
||||
"""Add ``attachments`` to recall results — the same per-fact edge as :func:`_attach_to_memories`.
|
||||
|
||||
Recall already reports the chunk each fact came from, and a chunk lists every
|
||||
attachment its text references; that is strictly coarser. A chunk holding a
|
||||
screenshot also holds the prose around it, so going through the chunk shows
|
||||
the screenshot against the paragraph that never mentioned it. This reads the
|
||||
edge the extractor recorded instead.
|
||||
|
||||
One lookup for the whole page. For a bank that has retained no attachments it
|
||||
is a single indexed read of the ids column that returns nothing to resolve,
|
||||
which is why this is unconditional rather than another `include` flag.
|
||||
"""
|
||||
unit_ids = [result.id for result in results if result.id]
|
||||
if not unit_ids:
|
||||
return
|
||||
by_unit = await memory_app.attachments_for_memories(bank_id, unit_ids, request_context)
|
||||
if not by_unit:
|
||||
return
|
||||
for result in results:
|
||||
records = by_unit.get(str(result.id))
|
||||
if records:
|
||||
result.attachments = [_attachment_model(bank_id, record) for record in records]
|
||||
|
||||
|
||||
def canonicalize_item_content(
|
||||
content: str | list[ContentBlock],
|
||||
*,
|
||||
@@ -5676,6 +5719,7 @@ def _register_routes(app: FastAPI):
|
||||
)
|
||||
|
||||
recall_results = [_fact_to_result(fact) for fact in core_result.results]
|
||||
await _attach_to_recall_results(app.state.memory, bank_id, recall_results, request_context)
|
||||
|
||||
# Convert chunks from engine to HTTP API format
|
||||
chunks_response = None
|
||||
|
||||
@@ -142,6 +142,14 @@ async def test_recall_returns_them_on_chunks_and_on_memories(api_client, bank_wi
|
||||
chunks = (body.get("chunks") or {}).values()
|
||||
_assert_handle(next(c["attachments"] for c in chunks if c.get("attachments")))
|
||||
|
||||
# And on the results themselves, so an agent that did not ask for chunks can
|
||||
# still show what a fact was drawn from. The two are not the same set: a
|
||||
# chunk lists everything its text references, a result only what the
|
||||
# extractor attributed to that fact.
|
||||
results = body["results"]
|
||||
assert results, "recall returned no results"
|
||||
_assert_handle(next(r["attachments"] for r in results if r.get("attachments")))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_text_only_bank_reports_no_attachments_anywhere(api_client):
|
||||
@@ -155,5 +163,11 @@ async def test_a_text_only_bank_reports_no_attachments_anywhere(api_client):
|
||||
document = await api_client.get(f"/v1/default/banks/{bank_id}/documents/plain")
|
||||
memories = await api_client.get(f"/v1/default/banks/{bank_id}/memories/list")
|
||||
|
||||
recall = await api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories/recall",
|
||||
json={"query": "what is the VPN client called"},
|
||||
)
|
||||
|
||||
assert document.json().get("attachments") is None
|
||||
assert all(m.get("attachments") is None for m in memories.json()["items"])
|
||||
assert all(r.get("attachments") is None for r in recall.json()["results"])
|
||||
|
||||
@@ -10535,6 +10535,11 @@ components:
|
||||
type: array
|
||||
scores:
|
||||
$ref: '#/components/schemas/RecallScores'
|
||||
attachments:
|
||||
items:
|
||||
$ref: '#/components/schemas/ChunkAttachment'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- id
|
||||
- text
|
||||
|
||||
@@ -35,6 +35,7 @@ type RecallResult struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
SourceFactIds []string `json:"source_fact_ids,omitempty"`
|
||||
Scores NullableRecallScores `json:"scores,omitempty"`
|
||||
Attachments []ChunkAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
type _RecallResult RecallResult
|
||||
@@ -574,6 +575,39 @@ func (o *RecallResult) UnsetScores() {
|
||||
o.Scores.Unset()
|
||||
}
|
||||
|
||||
// GetAttachments returns the Attachments field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *RecallResult) GetAttachments() []ChunkAttachment {
|
||||
if o == nil {
|
||||
var ret []ChunkAttachment
|
||||
return ret
|
||||
}
|
||||
return o.Attachments
|
||||
}
|
||||
|
||||
// GetAttachmentsOk returns a tuple with the Attachments field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
// NOTE: If the value is an explicit nil, `nil, true` will be returned
|
||||
func (o *RecallResult) GetAttachmentsOk() ([]ChunkAttachment, bool) {
|
||||
if o == nil || IsNil(o.Attachments) {
|
||||
return nil, false
|
||||
}
|
||||
return o.Attachments, true
|
||||
}
|
||||
|
||||
// HasAttachments returns a boolean if a field has been set.
|
||||
func (o *RecallResult) HasAttachments() bool {
|
||||
if o != nil && !IsNil(o.Attachments) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetAttachments gets a reference to the given []ChunkAttachment and assigns it to the Attachments field.
|
||||
func (o *RecallResult) SetAttachments(v []ChunkAttachment) {
|
||||
o.Attachments = v
|
||||
}
|
||||
|
||||
func (o RecallResult) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
@@ -622,6 +656,9 @@ func (o RecallResult) ToMap() (map[string]interface{}, error) {
|
||||
if o.Scores.IsSet() {
|
||||
toSerialize["scores"] = o.Scores.Get()
|
||||
}
|
||||
if o.Attachments != nil {
|
||||
toSerialize["attachments"] = o.Attachments
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from hindsight_client_api.models.chunk_attachment import ChunkAttachment
|
||||
from hindsight_client_api.models.recall_scores import RecallScores
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
@@ -41,7 +42,8 @@ class RecallResult(BaseModel):
|
||||
tags: Optional[List[StrictStr]] = None
|
||||
source_fact_ids: Optional[List[StrictStr]] = None
|
||||
scores: Optional[RecallScores] = None
|
||||
__properties: ClassVar[List[str]] = ["id", "text", "type", "entities", "context", "occurred_start", "occurred_end", "mentioned_at", "document_id", "metadata", "chunk_id", "tags", "source_fact_ids", "scores"]
|
||||
attachments: Optional[List[ChunkAttachment]] = None
|
||||
__properties: ClassVar[List[str]] = ["id", "text", "type", "entities", "context", "occurred_start", "occurred_end", "mentioned_at", "document_id", "metadata", "chunk_id", "tags", "source_fact_ids", "scores", "attachments"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -85,6 +87,13 @@ class RecallResult(BaseModel):
|
||||
# override the default output from pydantic by calling `to_dict()` of scores
|
||||
if self.scores:
|
||||
_dict['scores'] = self.scores.to_dict()
|
||||
# override the default output from pydantic by calling `to_dict()` of each item in attachments (list)
|
||||
_items = []
|
||||
if self.attachments:
|
||||
for _item_attachments in self.attachments:
|
||||
if _item_attachments:
|
||||
_items.append(_item_attachments.to_dict())
|
||||
_dict['attachments'] = _items
|
||||
# set to None if type (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.type is None and "type" in self.model_fields_set:
|
||||
@@ -145,6 +154,11 @@ class RecallResult(BaseModel):
|
||||
if self.scores is None and "scores" in self.model_fields_set:
|
||||
_dict['scores'] = None
|
||||
|
||||
# set to None if attachments (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.attachments is None and "attachments" in self.model_fields_set:
|
||||
_dict['attachments'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -170,7 +184,8 @@ class RecallResult(BaseModel):
|
||||
"chunk_id": obj.get("chunk_id"),
|
||||
"tags": obj.get("tags"),
|
||||
"source_fact_ids": obj.get("source_fact_ids"),
|
||||
"scores": RecallScores.from_dict(obj["scores"]) if obj.get("scores") is not None else None
|
||||
"scores": RecallScores.from_dict(obj["scores"]) if obj.get("scores") is not None else None,
|
||||
"attachments": [ChunkAttachment.from_dict(_item) for _item in obj["attachments"]] if obj.get("attachments") is not None else None
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -5273,6 +5273,12 @@ export type RecallResult = {
|
||||
*/
|
||||
source_fact_ids?: Array<string> | null;
|
||||
scores?: RecallScores | null;
|
||||
/**
|
||||
* Attachments
|
||||
*
|
||||
* Attachments this fact was drawn from, as recorded per fact at extraction time — the same edge the memory read endpoints return, not everything its chunk happened to carry. A fact stated in prose reports none. Omitted when there are none.
|
||||
*/
|
||||
attachments?: Array<ChunkAttachment> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,16 +37,78 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import JsonView from "react18-json-view";
|
||||
import "react18-json-view/src/style.css";
|
||||
import { MemoryDetailModal } from "./memory-detail-modal";
|
||||
import { AttachmentStrip, RetainedAttachment } from "@/components/ui/inline-attachment-text";
|
||||
import { EntityChip, TagChip } from "@/components/ui/facet-chip";
|
||||
|
||||
type Budget = "low" | "mid" | "high";
|
||||
type TagsMatch = "any" | "all" | "any_strict" | "all_strict" | "exact";
|
||||
type ViewMode = "results" | "trace" | "json";
|
||||
|
||||
// Render a score at FULL precision — never round. Rounded scores hide meaningful
|
||||
// differences (e.g. 0.001125 vs 0.001004 both render as "0.001"), which is exactly
|
||||
// what makes the reranker's behaviour hard to read. `null`/`undefined` → em dash.
|
||||
const fmtScore = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "—" : String(v);
|
||||
// Significant digits, never a fixed number of decimals. This used to print the
|
||||
// raw value for a reason: fusion scores cluster around 0.001, where toFixed(3)
|
||||
// collapses 0.001125 and 0.001004 to the same "0.001" and makes the reranker's
|
||||
// behaviour impossible to read. Four significant digits keeps those apart while
|
||||
// still trimming 0.8765526740786869 to 0.8766, and the exact value stays one
|
||||
// hover away wherever there is room for a tooltip. `null`/`undefined` → em dash.
|
||||
const fmtScore = (v: number | null | undefined): string => {
|
||||
if (v === null || v === undefined) return "—";
|
||||
if (!Number.isFinite(v)) return String(v);
|
||||
// `Number(...)` drops the trailing zeros toPrecision pads on (0.8 → "0.8000").
|
||||
return String(Number(v.toPrecision(4)));
|
||||
};
|
||||
|
||||
// Timestamps are rendered in UTC, matching what the temporal-window hint on this
|
||||
// same page tells you times are read as — and a fact the extractor dated to a
|
||||
// day arrives as midnight UTC, which in any other zone would shift it onto the
|
||||
// wrong date entirely.
|
||||
//
|
||||
// The time is dropped when it *is* that midnight: printing "00:00" beside a
|
||||
// date-only occurrence invents a precision the memory does not have.
|
||||
const UTC_DATE: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
};
|
||||
const UTC_TIME: Intl.DateTimeFormatOptions = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "UTC",
|
||||
};
|
||||
|
||||
const fmtWhen = (v: string | null | undefined): string => {
|
||||
if (!v) return "";
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) return String(v);
|
||||
const date = d.toLocaleDateString(undefined, UTC_DATE);
|
||||
const isDateOnly = d.getUTCHours() === 0 && d.getUTCMinutes() === 0 && d.getUTCSeconds() === 0;
|
||||
return isDateOnly ? date : `${date} ${d.toLocaleTimeString(undefined, UTC_TIME)}`;
|
||||
};
|
||||
|
||||
// A day-granularity occurrence is stored as the whole day — 00:00:00 to
|
||||
// 23:59:59.999 — so rendering it as a range prints "3 Mar 2024 → 3 Mar 2024
|
||||
// 23:59", which reads as a precision that was never claimed. Collapse it back to
|
||||
// the single date it means.
|
||||
const fmtWhenRange = (start: string, end: string | null | undefined): string => {
|
||||
if (!end || end === start) return fmtWhen(start);
|
||||
const from = new Date(start);
|
||||
const to = new Date(end);
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return fmtWhen(start);
|
||||
const sameDay = from.toISOString().slice(0, 10) === to.toISOString().slice(0, 10);
|
||||
const wholeDay =
|
||||
from.getUTCHours() === 0 &&
|
||||
from.getUTCMinutes() === 0 &&
|
||||
from.getUTCSeconds() === 0 &&
|
||||
to.getUTCHours() === 23 &&
|
||||
to.getUTCMinutes() === 59;
|
||||
if (sameDay && wholeDay) return fmtWhen(start);
|
||||
return `${fmtWhen(start)} → ${fmtWhen(end)}`;
|
||||
};
|
||||
|
||||
/** The unrounded value, for a `title` beside a rounded one. */
|
||||
const exactScore = (v: number | null | undefined): string | undefined =>
|
||||
v === null || v === undefined ? undefined : String(v);
|
||||
|
||||
export function SearchDebugView() {
|
||||
const t = useTranslations("searchDebug");
|
||||
@@ -59,7 +121,11 @@ export function SearchDebugView() {
|
||||
const [maxTokens, setMaxTokens] = useState(4096);
|
||||
const [queryDate, setQueryDate] = useState("");
|
||||
const [includeChunks, setIncludeChunks] = useState(false);
|
||||
const [includeEntities, setIncludeEntities] = useState(false);
|
||||
// On by default: the entity names shown as chips on each result come back only
|
||||
// when entities are included — the flag gates the names, not just the entity
|
||||
// observations block — and "what is this fact about" is the first thing worth
|
||||
// seeing in a results list. Untick it to recall without the extra lookup.
|
||||
const [includeEntities, setIncludeEntities] = useState(true);
|
||||
const [windowStart, setWindowStart] = useState("");
|
||||
const [windowEnd, setWindowEnd] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
@@ -68,7 +134,11 @@ export function SearchDebugView() {
|
||||
// Results state
|
||||
const [results, setResults] = useState<any[] | null>(null);
|
||||
const [entities, setEntities] = useState<any[] | null>(null);
|
||||
const [chunks, setChunks] = useState<any[] | null>(null);
|
||||
// Keyed by chunk id (`chunk_id -> ChunkData`), which is the shape the API
|
||||
// returns — it was typed as an array, which no consumer could index. Only the
|
||||
// JSON view reads it: results take their attachments from the fact's own edge,
|
||||
// never from the chunk (see attachmentsForResult).
|
||||
const [chunks, setChunks] = useState<Record<string, any> | null>(null);
|
||||
const [observations, setObservations] = useState<any[] | null>(null);
|
||||
const [trace, setTrace] = useState<any | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -111,6 +181,14 @@ export function SearchDebugView() {
|
||||
|
||||
const temporalWindow = resolveTemporalWindow(windowStart, windowEnd);
|
||||
|
||||
// Only the edge the extractor recorded for this fact. There is no falling back
|
||||
// to the chunk's attachments: a chunk lists everything its text references, so
|
||||
// a fact drawn from the prose beside a screenshot would be shown that
|
||||
// screenshot as its evidence. A fact the extractor attributed to nothing shows
|
||||
// nothing, which is the honest answer.
|
||||
const attachmentsForResult = (result: any): RetainedAttachment[] =>
|
||||
(result?.attachments as RetainedAttachment[] | undefined) ?? [];
|
||||
|
||||
const runSearch = async () => {
|
||||
// Guard here, not just on the button: Enter in the query box calls this
|
||||
// directly, and searching anyway would silently drop the window the user
|
||||
@@ -268,7 +346,7 @@ export function SearchDebugView() {
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{t("chunks")}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<label className="flex items-center gap-2 cursor-pointer" title={t("entitiesHint")}>
|
||||
<Checkbox
|
||||
checked={includeEntities}
|
||||
onCheckedChange={(c) => setIncludeEntities(c as boolean)}
|
||||
@@ -461,7 +539,7 @@ export function SearchDebugView() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-foreground">{result.text}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2 text-xs text-muted-foreground">
|
||||
<span className="px-2 py-0.5 rounded bg-muted capitalize">
|
||||
{result.type || "world"}
|
||||
</span>
|
||||
@@ -469,31 +547,86 @@ export function SearchDebugView() {
|
||||
<span className="truncate max-w-xs">{result.context}</span>
|
||||
)}
|
||||
{result.occurred_start && (
|
||||
<span>
|
||||
{new Date(result.occurred_start).toLocaleDateString()}
|
||||
<span
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap"
|
||||
title={result.occurred_start}
|
||||
>
|
||||
<Calendar className="h-3 w-3 shrink-0" />
|
||||
{t("occurredLabel")}{" "}
|
||||
{fmtWhenRange(result.occurred_start, result.occurred_end)}
|
||||
</span>
|
||||
)}
|
||||
{result.mentioned_at && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap"
|
||||
title={result.mentioned_at}
|
||||
>
|
||||
<Clock className="h-3 w-3 shrink-0" />
|
||||
{t("mentionedLabel")} {fmtWhen(result.mentioned_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result.scores && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2 text-[10px] text-muted-foreground font-mono">
|
||||
<span>final {fmtScore(result.scores.final)}</span>
|
||||
{result.scores.reranker !== null &&
|
||||
result.scores.reranker !== undefined && (
|
||||
<span>reranker {fmtScore(result.scores.reranker)}</span>
|
||||
)}
|
||||
{result.scores.semantic !== null &&
|
||||
result.scores.semantic !== undefined && (
|
||||
<span>semantic {fmtScore(result.scores.semantic)}</span>
|
||||
)}
|
||||
{result.scores.keyword !== null &&
|
||||
result.scores.keyword !== undefined && (
|
||||
<span>keyword {fmtScore(result.scores.keyword)}</span>
|
||||
)}
|
||||
{/* Boolean, not a bare length: `0 && ...` renders a
|
||||
literal "0" for a fact with empty arrays. */}
|
||||
{((result.entities?.length ?? 0) > 0 ||
|
||||
(result.tags?.length ?? 0) > 0) && (
|
||||
// Entities and tags, not the score breakdown:
|
||||
// what the fact is *about* is what a reader
|
||||
// scanning results needs. The per-signal scores
|
||||
// (semantic/keyword/reranker and the boosts they
|
||||
// feed) are a retrieval-debugging concern and
|
||||
// live in the Trace tab, which shows them per
|
||||
// stage rather than as a flat row here.
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{(result.entities ?? []).map((entity: string) => (
|
||||
<EntityChip
|
||||
key={`e-${entity}`}
|
||||
entity={entity}
|
||||
size="xs"
|
||||
truncate
|
||||
className="max-w-[220px]"
|
||||
/>
|
||||
))}
|
||||
{(result.tags ?? []).map((tag: string) => (
|
||||
<TagChip
|
||||
key={`t-${tag}`}
|
||||
tag={tag}
|
||||
size="xs"
|
||||
truncate
|
||||
className="max-w-[220px]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const attachments = attachmentsForResult(result);
|
||||
if (attachments.length === 0) return null;
|
||||
return (
|
||||
// Each attachment is a link to its own bytes;
|
||||
// without stopping the click here it would also
|
||||
// bubble to the card and open the memory dialog
|
||||
// on top of the image the reader just asked for.
|
||||
<div
|
||||
className="mt-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("factAttachments")}
|
||||
</div>
|
||||
<AttachmentStrip
|
||||
bankId={currentBank}
|
||||
attachments={attachments}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<div className="text-sm font-semibold">{fmtScore(score)}</div>
|
||||
<div className="text-sm font-semibold" title={exactScore(score)}>
|
||||
{fmtScore(score)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t("scoreLabel")}</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
@@ -1062,6 +1062,7 @@
|
||||
"queryDatePlaceholder": "Abfragedatum",
|
||||
"chunks": "Chunks",
|
||||
"entities": "Entitäten",
|
||||
"entitiesHint": "Gibt die Entitätsnamen zurück, die an jedem Ergebnis angezeigt werden, sowie den Block mit Entitätsbeobachtungen.",
|
||||
"tagsPlaceholder": "Nach Tags filtern (kommagetrennt)",
|
||||
"tagsMatchAny": "Beliebig (inkl. ohne Tags)",
|
||||
"tagsMatchAll": "Alle (inkl. ohne Tags)",
|
||||
@@ -1084,6 +1085,9 @@
|
||||
"relevance": "Relevanz: {value}",
|
||||
"noMemoriesFound": "Für diese Abfrage wurden keine Erinnerungen gefunden.",
|
||||
"scoreLabel": "Bewertung",
|
||||
"occurredLabel": "geschehen",
|
||||
"mentionedLabel": "erwähnt",
|
||||
"factAttachments": "Anhänge, aus denen dieser Fakt stammt",
|
||||
"parallelRetrieval": "PARALLELER ABRUF",
|
||||
"methodsCount": "{count} Methoden",
|
||||
"showLess": "Weniger anzeigen",
|
||||
|
||||
@@ -1062,6 +1062,7 @@
|
||||
"queryDatePlaceholder": "Query date",
|
||||
"chunks": "Chunks",
|
||||
"entities": "Entities",
|
||||
"entitiesHint": "Returns the entity names shown on each result, plus the entity observations block.",
|
||||
"tagsPlaceholder": "Filter by tags (comma-separated)",
|
||||
"tagsMatchAny": "Any (incl. untagged)",
|
||||
"tagsMatchAll": "All (incl. untagged)",
|
||||
@@ -1084,6 +1085,9 @@
|
||||
"relevance": "Relevance: {value}",
|
||||
"noMemoriesFound": "No memories found for this query.",
|
||||
"scoreLabel": "score",
|
||||
"occurredLabel": "occurred",
|
||||
"mentionedLabel": "mentioned",
|
||||
"factAttachments": "Attachments this fact came from",
|
||||
"parallelRetrieval": "PARALLEL RETRIEVAL",
|
||||
"methodsCount": "{count} methods",
|
||||
"showLess": "Show less",
|
||||
|
||||
@@ -1062,6 +1062,7 @@
|
||||
"queryDatePlaceholder": "Fecha de consulta",
|
||||
"chunks": "Fragmentos",
|
||||
"entities": "Entidades",
|
||||
"entitiesHint": "Devuelve los nombres de entidad que se muestran en cada resultado, además del bloque de observaciones de entidades.",
|
||||
"tagsPlaceholder": "Filtrar por etiquetas (separadas por comas)",
|
||||
"tagsMatchAny": "Cualquiera (incl. sin etiqueta)",
|
||||
"tagsMatchAll": "Todas (incl. sin etiqueta)",
|
||||
@@ -1084,6 +1085,9 @@
|
||||
"relevance": "Relevancia: {value}",
|
||||
"noMemoriesFound": "No se encontraron memorias para esta consulta.",
|
||||
"scoreLabel": "puntuación",
|
||||
"occurredLabel": "ocurrió",
|
||||
"mentionedLabel": "mencionado",
|
||||
"factAttachments": "Adjuntos de los que procede este hecho",
|
||||
"parallelRetrieval": "RECUPERACIÓN PARALELA",
|
||||
"methodsCount": "{count} métodos",
|
||||
"showLess": "Mostrar menos",
|
||||
|
||||
@@ -1062,6 +1062,7 @@
|
||||
"queryDatePlaceholder": "Date de la requête",
|
||||
"chunks": "Segments",
|
||||
"entities": "Entités",
|
||||
"entitiesHint": "Renvoie les noms d'entités affichés sur chaque résultat, ainsi que le bloc d'observations d'entités.",
|
||||
"tagsPlaceholder": "Filtrer par tags (séparés par des virgules)",
|
||||
"tagsMatchAny": "N'importe lequel (incl. non taggés)",
|
||||
"tagsMatchAll": "Tous (incl. non taggés)",
|
||||
@@ -1084,6 +1085,9 @@
|
||||
"relevance": "Pertinence : {value}",
|
||||
"noMemoriesFound": "Aucun souvenir trouvé pour cette requête.",
|
||||
"scoreLabel": "score",
|
||||
"occurredLabel": "survenu",
|
||||
"mentionedLabel": "mentionné",
|
||||
"factAttachments": "Pièces jointes dont ce fait provient",
|
||||
"parallelRetrieval": "RÉCUPÉRATION PARALLÈLE",
|
||||
"methodsCount": "{count} méthodes",
|
||||
"showLess": "Afficher moins",
|
||||
|
||||
@@ -1062,6 +1062,7 @@
|
||||
"queryDatePlaceholder": "クエリ日付",
|
||||
"chunks": "チャンク",
|
||||
"entities": "エンティティ",
|
||||
"entitiesHint": "各結果に表示されるエンティティ名と、エンティティの観測ブロックを返します。",
|
||||
"tagsPlaceholder": "タグでフィルター(カンマ区切り)",
|
||||
"tagsMatchAny": "いずれか(タグなし含む)",
|
||||
"tagsMatchAll": "すべて(タグなし含む)",
|
||||
@@ -1084,6 +1085,9 @@
|
||||
"relevance": "関連性:{value}",
|
||||
"noMemoriesFound": "このクエリに該当するメモリが見つかりませんでした。",
|
||||
"scoreLabel": "スコア",
|
||||
"occurredLabel": "発生",
|
||||
"mentionedLabel": "言及",
|
||||
"factAttachments": "このファクトの由来となった添付ファイル",
|
||||
"parallelRetrieval": "並列取得",
|
||||
"methodsCount": "{count}メソッド",
|
||||
"showLess": "表示を減らす",
|
||||
|
||||
@@ -1062,6 +1062,7 @@
|
||||
"queryDatePlaceholder": "쿼리 날짜",
|
||||
"chunks": "청크",
|
||||
"entities": "엔티티",
|
||||
"entitiesHint": "각 결과에 표시되는 엔티티 이름과 엔티티 관찰 블록을 함께 반환합니다.",
|
||||
"tagsPlaceholder": "태그로 필터링 (쉼표로 구분)",
|
||||
"tagsMatchAny": "모두 (태그 없는 항목 포함)",
|
||||
"tagsMatchAll": "전체 (태그 없는 항목 포함)",
|
||||
@@ -1084,6 +1085,9 @@
|
||||
"relevance": "관련도: {value}",
|
||||
"noMemoriesFound": "이 쿼리에 대한 메모리를 찾을 수 없습니다.",
|
||||
"scoreLabel": "점수",
|
||||
"occurredLabel": "발생",
|
||||
"mentionedLabel": "언급",
|
||||
"factAttachments": "이 사실의 출처가 된 첨부 파일",
|
||||
"parallelRetrieval": "병렬 검색",
|
||||
"methodsCount": "{count}개 방법",
|
||||
"showLess": "간략히 보기",
|
||||
|
||||
@@ -1062,6 +1062,7 @@
|
||||
"queryDatePlaceholder": "Data da consulta",
|
||||
"chunks": "Chunks",
|
||||
"entities": "Entidades",
|
||||
"entitiesHint": "Devolve os nomes de entidade mostrados em cada resultado, além do bloco de observações de entidades.",
|
||||
"tagsPlaceholder": "Filtrar por tags (separadas por vírgula)",
|
||||
"tagsMatchAny": "Qualquer (incl. sem tag)",
|
||||
"tagsMatchAll": "Todos (incl. sem tag)",
|
||||
@@ -1084,6 +1085,9 @@
|
||||
"relevance": "Relevância: {value}",
|
||||
"noMemoriesFound": "Nenhuma memória encontrada para esta consulta.",
|
||||
"scoreLabel": "pontuação",
|
||||
"occurredLabel": "ocorreu",
|
||||
"mentionedLabel": "mencionado",
|
||||
"factAttachments": "Anexos de onde este facto veio",
|
||||
"parallelRetrieval": "RECUPERAÇÃO PARALELA",
|
||||
"methodsCount": "{count} métodos",
|
||||
"showLess": "Mostrar menos",
|
||||
|
||||
@@ -1062,6 +1062,7 @@
|
||||
"queryDatePlaceholder": "查詢日期",
|
||||
"chunks": "片段",
|
||||
"entities": "實體",
|
||||
"entitiesHint": "回傳每個結果上顯示嘅實體名稱,同埋實體觀察區塊。",
|
||||
"tagsPlaceholder": "按標籤篩選(逗號分隔)",
|
||||
"tagsMatchAny": "任何(含未標記)",
|
||||
"tagsMatchAll": "全部(含未標記)",
|
||||
@@ -1084,6 +1085,9 @@
|
||||
"relevance": "相關度:{value}",
|
||||
"noMemoriesFound": "找不到符合此查詢的記憶。",
|
||||
"scoreLabel": "分數",
|
||||
"occurredLabel": "發生於",
|
||||
"mentionedLabel": "提及於",
|
||||
"factAttachments": "呢個事實嚟自嘅附件",
|
||||
"parallelRetrieval": "並行檢索",
|
||||
"methodsCount": "{count} 種方法",
|
||||
"showLess": "收合",
|
||||
|
||||
@@ -1062,6 +1062,7 @@
|
||||
"queryDatePlaceholder": "查询日期",
|
||||
"chunks": "分块",
|
||||
"entities": "实体",
|
||||
"entitiesHint": "返回每条结果上显示的实体名称,以及实体观察块。",
|
||||
"tagsPlaceholder": "按标签过滤(逗号分隔)",
|
||||
"tagsMatchAny": "任意(含未标记)",
|
||||
"tagsMatchAll": "全部(含未标记)",
|
||||
@@ -1084,6 +1085,9 @@
|
||||
"relevance": "相关度:{value}",
|
||||
"noMemoriesFound": "没有找到匹配此查询的记忆。",
|
||||
"scoreLabel": "分数",
|
||||
"occurredLabel": "发生于",
|
||||
"mentionedLabel": "提及于",
|
||||
"factAttachments": "该事实来源的附件",
|
||||
"parallelRetrieval": "并行检索",
|
||||
"methodsCount": "{count} 种方法",
|
||||
"showLess": "收起",
|
||||
|
||||
@@ -1062,6 +1062,7 @@
|
||||
"queryDatePlaceholder": "查詢日期",
|
||||
"chunks": "片段",
|
||||
"entities": "實體",
|
||||
"entitiesHint": "回傳每筆結果上顯示的實體名稱,以及實體觀察區塊。",
|
||||
"tagsPlaceholder": "按標籤篩選(逗號分隔)",
|
||||
"tagsMatchAny": "任一(含未標記)",
|
||||
"tagsMatchAll": "全部(含未標記)",
|
||||
@@ -1084,6 +1085,9 @@
|
||||
"relevance": "相關度:{value}",
|
||||
"noMemoriesFound": "沒有找到符合此查詢的記憶。",
|
||||
"scoreLabel": "分數",
|
||||
"occurredLabel": "發生於",
|
||||
"mentionedLabel": "提及於",
|
||||
"factAttachments": "此事實來源的附件",
|
||||
"parallelRetrieval": "並行檢索",
|
||||
"methodsCount": "{count} 種方法",
|
||||
"showLess": "收合",
|
||||
|
||||
@@ -15649,6 +15649,21 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachments": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ChunkAttachment"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Attachments",
|
||||
"description": "Attachments this fact was drawn from, as recorded per fact at extraction time \u2014 the same edge the memory read endpoints return, not everything its chunk happened to carry. A fact stated in prose reports none. Omitted when there are none."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
||||
@@ -15649,6 +15649,21 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachments": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ChunkAttachment"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Attachments",
|
||||
"description": "Attachments this fact was drawn from, as recorded per fact at extraction time \u2014 the same edge the memory read endpoints return, not everything its chunk happened to carry. A fact stated in prose reports none. Omitted when there are none."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
||||
Reference in New Issue
Block a user