Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8afec653d8 | ||
|
|
b68c61f672 | ||
|
|
041f0f13f4 | ||
|
|
9dee1c594b | ||
|
|
a1ebb2d9c6 | ||
|
|
06ddf041e5 | ||
|
|
d251fcb7d2 | ||
|
|
e839c65537 | ||
|
|
0cde79b831 | ||
|
|
8767a518db | ||
|
|
10ed288d80 | ||
|
|
7143684a81 | ||
|
|
11da432db2 | ||
|
|
73d3231bbd | ||
|
|
59d825dfca | ||
|
|
ae7099fd02 | ||
|
|
56db6d7cf6 | ||
|
|
0eb52762ae | ||
|
|
e93c560288 | ||
|
|
1f213d00f7 | ||
|
|
8f51f99dde | ||
|
|
5cc1482a72 | ||
|
|
639d84ad32 | ||
|
|
1c74f795a6 | ||
|
|
b4f9fbe1b5 | ||
|
|
b992ba996d | ||
|
|
f00d3c7f66 | ||
|
|
e97b615547 | ||
|
|
29cc1d7fdc | ||
|
|
016b5f0363 | ||
|
|
dd7e252452 | ||
|
|
fda1a77f70 | ||
|
|
0accef8e98 | ||
|
|
c77e2368de | ||
|
|
38ef0247c2 | ||
|
|
a158b819f3 | ||
|
|
381963c28a | ||
|
|
ba158c9cdb | ||
|
|
767a2c0061 | ||
|
|
36334f27a1 | ||
|
|
6a479dddb9 | ||
|
|
7058d1aad7 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "hindsight",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"description": "Official Hindsight integrations for Claude Code",
|
||||
"owner": {
|
||||
"name": "vectorize-io"
|
||||
|
||||
@@ -115,6 +115,11 @@ HINDSIGHT_API_LOG_LEVEL=info
|
||||
# chinese_lindera/lindera(chinese), japanese_lindera/lindera(japanese),
|
||||
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
|
||||
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
|
||||
# Optional cap on the number of terms in the native PostgreSQL BM25 tsquery.
|
||||
# Long queries OR-join every normalized token, which can match too much of a
|
||||
# large bank. 0 (default) keeps the historical uncapped behavior; a positive
|
||||
# value bounds only the native backend (other BM25 backends get the raw query).
|
||||
# HINDSIGHT_API_BM25_MAX_QUERY_TERMS=0
|
||||
|
||||
# File Parser (Optional - uses markitdown by default)
|
||||
# HINDSIGHT_API_FILE_PARSER=markitdown
|
||||
|
||||
@@ -520,22 +520,17 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install package and pytest
|
||||
working-directory: ./hindsight-integrations/zed
|
||||
# Installs the package (incl. the zstandard runtime dep) so the threads.db
|
||||
# reader tests can decompress Zed's zstd blobs.
|
||||
run: pip install -e . pytest
|
||||
node-version: '22'
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/zed
|
||||
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
|
||||
# (requires_real_llm) needs a live Hindsight server and runs separately.
|
||||
run: python -m pytest tests/ -v -m "not requires_real_llm"
|
||||
# Config-only integration with no dependencies — it uses Node's built-in
|
||||
# test runner. The runtime MCP bridge is `npx mcp-remote` (Node), so this
|
||||
# integration requires only Node.js (no Python).
|
||||
run: npm test
|
||||
|
||||
test-omo-integration:
|
||||
needs: [detect-changes]
|
||||
|
||||
@@ -56,7 +56,6 @@ BACKUP_TABLES = [
|
||||
"observation_history",
|
||||
"mental_models",
|
||||
"mental_model_history",
|
||||
"knowledge_pages",
|
||||
"directives",
|
||||
"async_operations",
|
||||
"webhooks",
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
"""Add managed flag to knowledge_pages.
|
||||
|
||||
The knowledge base is managed by clients (CRUD over folders/pages). ``managed``
|
||||
lets a client tag a node as system-owned vs. hand-authored; it carries no
|
||||
server-side behaviour.
|
||||
|
||||
Revision ID: a5b6c7d8e9f0
|
||||
Revises: a9b8c7d6e5f4
|
||||
Create Date: 2026-06-26
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "a5b6c7d8e9f0"
|
||||
down_revision: str | Sequence[str] | None = "a9b8c7d6e5f4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _pg_schema_prefix() -> str:
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
op.execute(f"ALTER TABLE {schema}knowledge_pages ADD COLUMN IF NOT EXISTS managed BOOLEAN NOT NULL DEFAULT false")
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
op.execute(f"ALTER TABLE {schema}knowledge_pages DROP COLUMN IF EXISTS managed")
|
||||
|
||||
|
||||
def _oracle_upgrade() -> None:
|
||||
op.execute("ALTER TABLE knowledge_pages ADD (managed NUMBER(1) DEFAULT 0 NOT NULL)")
|
||||
|
||||
|
||||
def _oracle_downgrade() -> None:
|
||||
op.execute("ALTER TABLE knowledge_pages DROP COLUMN managed")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
"""Add knowledge_pages table (knowledge-base hierarchy).
|
||||
|
||||
The knowledge base organizes synthesized mental models into a navigable tree of
|
||||
**folders** and **pages**. A page references the mental model that holds its
|
||||
content (``mental_model_id``); a folder is a pure container (``mental_model_id``
|
||||
NULL). Hierarchy is a single self-referential ``parent_id`` so folders can nest
|
||||
arbitrarily. Content stays in ``mental_models`` — this table is metadata + tree
|
||||
structure only.
|
||||
|
||||
Revision ID: a9b8c7d6e5f4
|
||||
Revises: b57a7c9e0d13
|
||||
Create Date: 2026-06-25
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "a9b8c7d6e5f4"
|
||||
down_revision: str | Sequence[str] | None = "b57a7c9e0d13"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _pg_schema_prefix() -> str:
|
||||
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
# parent_id self-FK cascades so deleting a folder row removes its whole
|
||||
# subtree of rows in one shot. The mental_model FK is composite (matches the
|
||||
# mental_models (id, bank_id) PK) and cascades too, so deleting a page's
|
||||
# mental model removes the page row — folders skip the FK because a NULL
|
||||
# column in a composite FK is not enforced (MATCH SIMPLE).
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {schema}knowledge_pages (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
bank_id TEXT NOT NULL,
|
||||
parent_id VARCHAR(64),
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
mental_model_id VARCHAR(64),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
|
||||
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
|
||||
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
|
||||
REFERENCES {schema}banks(bank_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
|
||||
REFERENCES {schema}knowledge_pages(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
|
||||
REFERENCES {schema}mental_models(id, bank_id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS idx_kp_bank_parent ON {schema}knowledge_pages (bank_id, parent_id, sort_order)"
|
||||
)
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_kp_bank_parent")
|
||||
op.execute(f"DROP TABLE IF EXISTS {schema}knowledge_pages")
|
||||
|
||||
|
||||
def _oracle_upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS knowledge_pages (
|
||||
id VARCHAR2(64) NOT NULL,
|
||||
bank_id VARCHAR2(256) NOT NULL,
|
||||
parent_id VARCHAR2(64),
|
||||
kind VARCHAR2(16) NOT NULL,
|
||||
name CLOB NOT NULL,
|
||||
mental_model_id VARCHAR2(64),
|
||||
sort_order NUMBER DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
|
||||
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
|
||||
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
|
||||
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
|
||||
REFERENCES banks(bank_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
|
||||
REFERENCES knowledge_pages(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
|
||||
REFERENCES mental_models(id, bank_id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX idx_kp_bank_parent ON knowledge_pages (bank_id, parent_id, sort_order)")
|
||||
|
||||
|
||||
def _oracle_downgrade() -> None:
|
||||
op.execute("DROP TABLE knowledge_pages CASCADE CONSTRAINTS")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
"""Unique page name per folder in knowledge_pages.
|
||||
|
||||
The folder curator can fire concurrently (folder-create trigger + the
|
||||
post-consolidation sweep), and an in-process lock can't serialize runs that
|
||||
execute in different threads/loops. A partial unique index on
|
||||
(bank_id, parent, lower(name)) for pages makes duplicate-named pages in the same
|
||||
folder impossible at the DB level — the second concurrent insert fails and the
|
||||
curator treats it as "already exists".
|
||||
|
||||
PostgreSQL only: the Oracle ``name`` column is a CLOB and cannot back a
|
||||
functional unique index; Oracle relies on the in-process serialization instead.
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: a5b6c7d8e9f0
|
||||
Create Date: 2026-06-26
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "c3d4e5f6a7b8"
|
||||
down_revision: str | Sequence[str] | None = "a5b6c7d8e9f0"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _pg_schema_prefix() -> str:
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
# First drop any pre-existing duplicate pages (created by the racy curator
|
||||
# before this guard existed), keeping the earliest row of each duplicate set,
|
||||
# so the unique index can be built. Their backing mental models are left in
|
||||
# place (harmless orphans).
|
||||
op.execute(
|
||||
f"""
|
||||
DELETE FROM {schema}knowledge_pages a
|
||||
USING {schema}knowledge_pages b
|
||||
WHERE a.kind = 'page' AND b.kind = 'page'
|
||||
AND a.bank_id = b.bank_id
|
||||
AND COALESCE(a.parent_id, '') = COALESCE(b.parent_id, '')
|
||||
AND lower(a.name) = lower(b.name)
|
||||
AND a.ctid > b.ctid
|
||||
"""
|
||||
)
|
||||
# COALESCE(parent_id, '') so root-level pages (NULL parent) are also unique by
|
||||
# name — NULLs would otherwise compare distinct and allow duplicates.
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_kp_folder_pagename "
|
||||
f"ON {schema}knowledge_pages (bank_id, COALESCE(parent_id, ''), lower(name)) "
|
||||
"WHERE kind = 'page'"
|
||||
)
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}uq_kp_folder_pagename")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent (CLOB name)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade)
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
"""Drop the search_vector column from the curation archive (invalidated_memory_units).
|
||||
|
||||
The archive is cold storage, never a recall surface, and carries no text-search
|
||||
index. Like ``embedding`` (dropped in d4f6a8c2e1b3), ``search_vector`` is a
|
||||
recall-surface column whose type follows the configured text-search backend, so
|
||||
it has no business living on the archive. Earlier curation code copied the live
|
||||
row's ``search_vector`` into ``invalidated_memory_units`` on invalidate; the
|
||||
engine now leaves it out on invalidate and recomputes it on revert, so the
|
||||
column is dead weight.
|
||||
|
||||
Dropping it removes a latent failure mode (#2503): under a non-native backend
|
||||
(pgroonga / pg_textsearch / pg_search / vchord) ``ensure_text_search_extension``
|
||||
reconciles ``memory_units.search_vector`` to ``text`` / ``bm25vector`` but never
|
||||
touched the archive, which the ``LIKE memory_units`` clone (c9a1b2d3e4f5) created
|
||||
as ``tsvector``. The type mismatch then broke the curation INSERT … SELECT
|
||||
round-trip:
|
||||
|
||||
column "search_vector" is of type tsvector but expression is of type text
|
||||
|
||||
With no column at all, there is nothing to mismatch. Unlike ``embedding`` (whose
|
||||
creation sites already omit it), the ``LIKE`` clone still adds ``search_vector``,
|
||||
so this migration does real work on both fresh and existing PostgreSQL databases.
|
||||
|
||||
DROP COLUMN is a metadata-only operation on both PostgreSQL and Oracle 23ai (no
|
||||
table rewrite), so it is cheap even across many tenant schemas. The downgrade
|
||||
re-adds an empty ``tsvector`` column (its original creation type).
|
||||
|
||||
Revision ID: e7c3a9f1b2d5
|
||||
Revises: b57a7c9e0d13
|
||||
Create Date: 2026-07-02
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
from hindsight_api.alembic._dialect import run_for_dialect
|
||||
|
||||
revision: str = "e7c3a9f1b2d5"
|
||||
down_revision: str | Sequence[str] | None = "b57a7c9e0d13"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _pg_schema_prefix() -> str:
|
||||
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def _pg_upgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS search_vector")
|
||||
|
||||
|
||||
def _pg_downgrade() -> None:
|
||||
schema = _pg_schema_prefix()
|
||||
# Re-add as the original tsvector creation type; comes back empty regardless.
|
||||
op.execute(f"ALTER TABLE {schema}invalidated_memory_units ADD COLUMN IF NOT EXISTS search_vector tsvector")
|
||||
|
||||
|
||||
def _oracle_upgrade() -> None:
|
||||
# Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not
|
||||
# exist) so the migration is idempotent and safe on a schema whose baseline
|
||||
# may already omit the column.
|
||||
op.execute(
|
||||
"""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN search_vector';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
IF SQLCODE != -904 THEN RAISE; END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _oracle_downgrade() -> None:
|
||||
# Swallow ORA-01430 (column already exists) for idempotency. Oracle stores
|
||||
# search_vector as CLOB (see the Oracle baseline), so re-add it as CLOB.
|
||||
op.execute(
|
||||
"""
|
||||
BEGIN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (search_vector CLOB)';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
IF SQLCODE != -1430 THEN RAISE; END IF;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
|
||||
@@ -18,7 +18,6 @@ from typing import Any, Literal, TypeVar
|
||||
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from hindsight_api.api import okf
|
||||
from hindsight_api.api.disconnect import ClientDisconnectCancellationMiddleware, get_scope_cancellation_token
|
||||
from hindsight_api.cancellation import OperationCancelledError
|
||||
from hindsight_api.engine.audit import (
|
||||
@@ -53,6 +52,7 @@ from fastapi.routing import APIRoute
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.config import RETAIN_EXTRACTION_MODES
|
||||
|
||||
|
||||
def _annotation_is_nullable(annotation: Any) -> bool:
|
||||
@@ -1246,7 +1246,7 @@ class CreateBankRequest(BaseModel):
|
||||
)
|
||||
retain_extraction_mode: str | None = Field(
|
||||
default=None,
|
||||
description="Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.",
|
||||
description="Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.",
|
||||
)
|
||||
retain_custom_instructions: str | None = Field(
|
||||
default=None,
|
||||
@@ -1434,6 +1434,7 @@ class ListMemoryUnitsResponse(BaseModel):
|
||||
"date": "2024-01-15T10:30:00Z",
|
||||
"type": "world",
|
||||
"entities": "Alice (PERSON), Google (ORGANIZATION)",
|
||||
"metadata": {"source": "slack", "channel": "engineering"},
|
||||
}
|
||||
],
|
||||
"total": 150,
|
||||
@@ -1667,8 +1668,8 @@ class UpdateMemoryRequest(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_an_edit(self) -> "UpdateMemoryRequest":
|
||||
if all(
|
||||
v is None
|
||||
has_value_edit = any(
|
||||
v is not None
|
||||
for v in (
|
||||
self.text,
|
||||
self.context,
|
||||
@@ -1678,7 +1679,9 @@ class UpdateMemoryRequest(BaseModel):
|
||||
self.entities,
|
||||
self.state,
|
||||
)
|
||||
):
|
||||
)
|
||||
has_date_clear = bool({"occurred_start", "occurred_end"} & self.model_fields_set)
|
||||
if not has_value_edit and not has_date_clear:
|
||||
raise ValueError("Provide at least one field to update.")
|
||||
if self.state is not None and self.state not in ("valid", "invalidated"):
|
||||
raise ValueError("state must be 'valid' or 'invalidated'.")
|
||||
@@ -2111,150 +2114,6 @@ class MentalModelListResponse(BaseModel):
|
||||
items: list[MentalModelResponse]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# KNOWLEDGE BASE (folders + pages over mental models, projected to OKF)
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class KnowledgeNode(BaseModel):
|
||||
"""A node in the knowledge-base tree — a folder or a page.
|
||||
|
||||
Pages carry ``description``/``tags`` from their backing mental model. The
|
||||
knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node
|
||||
as system-owned vs. hand-authored.
|
||||
"""
|
||||
|
||||
id: str
|
||||
kind: Literal["folder", "page"]
|
||||
name: str
|
||||
parent_id: str | None = None
|
||||
mental_model_id: str | None = Field(default=None, description="Backing mental model id (pages only).")
|
||||
managed: bool = Field(default=False, description="Client-set flag: true = system-owned, false = hand-authored.")
|
||||
description: str | None = Field(default=None, description="Page source query (OKF `description`).")
|
||||
tags: list[str] = FieldWithDefault(list)
|
||||
timestamp: str | None = Field(default=None, description="Last refresh (page) or last update (folder).")
|
||||
children: list["KnowledgeNode"] = FieldWithDefault(list)
|
||||
|
||||
|
||||
class KnowledgeTreeResponse(BaseModel):
|
||||
"""The knowledge base as a nested folder/page tree."""
|
||||
|
||||
roots: list[KnowledgeNode]
|
||||
|
||||
|
||||
class CreateFolderRequest(BaseModel):
|
||||
"""Create a folder under an optional parent folder."""
|
||||
|
||||
name: str
|
||||
parent_id: str | None = None
|
||||
|
||||
|
||||
class CreatePageRequest(BaseModel):
|
||||
"""Create a page (a mental model + tree node) under an optional parent folder."""
|
||||
|
||||
name: str
|
||||
source_query: str
|
||||
parent_id: str | None = None
|
||||
tags: list[str] | None = None
|
||||
max_tokens: int | None = None
|
||||
trigger: MentalModelTrigger | None = None
|
||||
|
||||
|
||||
class UpdateNodeRequest(BaseModel):
|
||||
"""Rename and/or move a node. Each field applies only when present."""
|
||||
|
||||
name: str | None = None
|
||||
parent_id: str | None = None
|
||||
|
||||
|
||||
class CreateKnowledgePageResponse(BaseModel):
|
||||
"""Result of creating a page: the node id, its mental model, and the refresh op."""
|
||||
|
||||
page_id: str
|
||||
mental_model_id: str
|
||||
operation_id: str | None = None
|
||||
|
||||
|
||||
class KnowledgePageResponse(BaseModel):
|
||||
"""A knowledge page rendered as an OKF document."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: str = Field(description="OKF document type — from a `type:<x>` tag, else 'knowledge-page'.")
|
||||
description: str | None = Field(default=None, description="The source query that rebuilds the page.")
|
||||
tags: list[str] = FieldWithDefault(list)
|
||||
timestamp: str | None = Field(default=None, description="Last refresh time (falls back to creation).")
|
||||
body: str | None = Field(default=None, description="The page's synthesized markdown body.")
|
||||
markdown: str = Field(description="The full OKF document: YAML frontmatter + markdown body.")
|
||||
|
||||
|
||||
class KnowledgePageGraphResponse(BaseModel):
|
||||
"""Constellation graph of knowledge pages linked by shared tags."""
|
||||
|
||||
nodes: list[dict[str, Any]]
|
||||
edges: list[dict[str, Any]]
|
||||
total_pages: int
|
||||
total_edges: int
|
||||
|
||||
|
||||
class KnowledgePageBundleFile(BaseModel):
|
||||
"""One file in a portable OKF bundle."""
|
||||
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
class KnowledgePageBundleResponse(BaseModel):
|
||||
"""A portable OKF bundle — a flat set of markdown files (index + pages + logs)."""
|
||||
|
||||
files: list[KnowledgePageBundleFile]
|
||||
|
||||
|
||||
def _knowledge_node_model(node: dict[str, Any]) -> KnowledgeNode:
|
||||
"""Project an engine node dict into a (childless) KnowledgeNode."""
|
||||
is_page = node.get("kind") == "page"
|
||||
return KnowledgeNode(
|
||||
id=node["id"],
|
||||
kind=node["kind"],
|
||||
name=node["name"],
|
||||
parent_id=node.get("parent_id"),
|
||||
mental_model_id=node.get("mental_model_id"),
|
||||
managed=bool(node.get("managed")),
|
||||
description=node.get("source_query") if is_page else None,
|
||||
tags=list(node.get("tags") or []) if is_page else [],
|
||||
timestamp=(node.get("last_refreshed_at") if is_page else node.get("updated_at")),
|
||||
)
|
||||
|
||||
|
||||
def _build_knowledge_tree(nodes: list[dict[str, Any]]) -> list[KnowledgeNode]:
|
||||
"""Assemble the flat node list into a nested tree of roots."""
|
||||
models = {n["id"]: _knowledge_node_model(n) for n in nodes}
|
||||
roots: list[KnowledgeNode] = []
|
||||
for node in nodes:
|
||||
model = models[node["id"]]
|
||||
parent_id = node.get("parent_id")
|
||||
if parent_id and parent_id in models:
|
||||
models[parent_id].children.append(model)
|
||||
else:
|
||||
roots.append(model)
|
||||
return roots
|
||||
|
||||
|
||||
def _knowledge_page_response(node: dict[str, Any]) -> KnowledgePageResponse:
|
||||
"""Project a page node (with merged mental-model content) into an OKF document."""
|
||||
page = okf.page_type(node.get("tags"))
|
||||
return KnowledgePageResponse(
|
||||
id=node["id"],
|
||||
name=node["name"],
|
||||
type=page.type,
|
||||
description=node.get("source_query"),
|
||||
tags=page.display_tags,
|
||||
timestamp=node.get("last_refreshed_at") or node.get("created_at"),
|
||||
body=node.get("content"),
|
||||
markdown=okf.render_document(node),
|
||||
)
|
||||
|
||||
|
||||
class CreateMentalModelRequest(BaseModel):
|
||||
"""Request model for creating a mental model."""
|
||||
|
||||
@@ -2348,7 +2207,8 @@ class BankTemplateConfig(BaseModel):
|
||||
reflect_mission: str | None = Field(default=None, description="Mission/context for Reflect operations")
|
||||
retain_mission: str | None = Field(default=None, description="Steers what gets extracted during retain")
|
||||
retain_extraction_mode: str | None = Field(
|
||||
default=None, description="Fact extraction mode: 'concise' (default), 'verbose', or 'custom'"
|
||||
default=None,
|
||||
description="Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'",
|
||||
)
|
||||
retain_custom_instructions: str | None = Field(
|
||||
default=None, description="Custom extraction prompt (when mode='custom')"
|
||||
@@ -2574,10 +2434,10 @@ def validate_bank_template(manifest: "BankTemplateManifest") -> list[str]:
|
||||
if manifest.bank:
|
||||
bank = manifest.bank
|
||||
if bank.retain_extraction_mode is not None:
|
||||
valid_modes = ("concise", "verbose", "custom", "chunks")
|
||||
if bank.retain_extraction_mode not in valid_modes:
|
||||
if bank.retain_extraction_mode not in RETAIN_EXTRACTION_MODES:
|
||||
errors.append(
|
||||
f"bank.retain_extraction_mode: must be one of {valid_modes}, got '{bank.retain_extraction_mode}'"
|
||||
"bank.retain_extraction_mode: "
|
||||
f"must be one of {RETAIN_EXTRACTION_MODES}, got '{bank.retain_extraction_mode}'"
|
||||
)
|
||||
if bank.retain_custom_instructions and bank.retain_extraction_mode != "custom":
|
||||
errors.append("bank.retain_custom_instructions: requires retain_extraction_mode='custom'")
|
||||
@@ -3895,13 +3755,23 @@ def _register_routes(app: FastAPI):
|
||||
):
|
||||
"""Curate a single memory unit (edit text / invalidate / revert)."""
|
||||
try:
|
||||
occurred_start = (
|
||||
""
|
||||
if "occurred_start" in request.model_fields_set and request.occurred_start is None
|
||||
else request.occurred_start
|
||||
)
|
||||
occurred_end = (
|
||||
""
|
||||
if "occurred_end" in request.model_fields_set and request.occurred_end is None
|
||||
else request.occurred_end
|
||||
)
|
||||
data = await app.state.memory.update_memory_unit(
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_id,
|
||||
text=request.text,
|
||||
context=request.context,
|
||||
occurred_start=request.occurred_start,
|
||||
occurred_end=request.occurred_end,
|
||||
occurred_start=occurred_start,
|
||||
occurred_end=occurred_end,
|
||||
new_fact_type=request.fact_type,
|
||||
entities=request.entities,
|
||||
state=request.state,
|
||||
@@ -4926,333 +4796,6 @@ def _register_routes(app: FastAPI):
|
||||
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# =========================================================================
|
||||
# KNOWLEDGE BASE ENDPOINTS (folders + pages, Open Knowledge Format)
|
||||
# =========================================================================
|
||||
# A hierarchy of folders and pages over mental models. Pages project to OKF
|
||||
# documents (markdown body + YAML frontmatter); see api/okf.py. The static
|
||||
# sub-paths (/tree, /folders, /pages, /graph, /export) are declared before
|
||||
# the /pages/{id} and /nodes/{id} path-parameter routes so they win.
|
||||
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/knowledge-base/tree",
|
||||
response_model=KnowledgeTreeResponse,
|
||||
summary="Get the knowledge-base tree",
|
||||
description="Return the knowledge base as a nested tree of folders and pages.",
|
||||
operation_id="get_knowledge_base_tree",
|
||||
tags=["Knowledge Base"],
|
||||
)
|
||||
async def api_knowledge_base_tree(
|
||||
bank_id: str,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Return the folder/page tree for a bank."""
|
||||
try:
|
||||
nodes = await app.state.memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
|
||||
return KnowledgeTreeResponse(roots=_build_knowledge_tree(nodes))
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/tree: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post(
|
||||
"/v1/default/banks/{bank_id}/knowledge-base/folders",
|
||||
response_model=KnowledgeNode,
|
||||
status_code=201,
|
||||
summary="Create a knowledge-base folder",
|
||||
description="Create a folder, optionally nested under a parent folder.",
|
||||
operation_id="create_knowledge_folder",
|
||||
tags=["Knowledge Base"],
|
||||
)
|
||||
async def api_create_knowledge_folder(
|
||||
bank_id: str,
|
||||
body: CreateFolderRequest,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Create a folder node."""
|
||||
try:
|
||||
node = await app.state.memory.create_knowledge_folder(
|
||||
bank_id=bank_id,
|
||||
name=body.name,
|
||||
parent_id=body.parent_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
return _knowledge_node_model(node)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in POST /v1/default/banks/{bank_id}/knowledge-base/folders: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post(
|
||||
"/v1/default/banks/{bank_id}/knowledge-base/pages",
|
||||
response_model=CreateKnowledgePageResponse,
|
||||
status_code=201,
|
||||
summary="Create a knowledge-base page",
|
||||
description="Create a page (a mental model + tree node). Content is generated asynchronously; "
|
||||
"use the returned operation_id to track completion.",
|
||||
operation_id="create_knowledge_page",
|
||||
tags=["Knowledge Base"],
|
||||
)
|
||||
async def api_create_knowledge_page(
|
||||
bank_id: str,
|
||||
body: CreatePageRequest,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Create a page node (async content generation)."""
|
||||
try:
|
||||
node = await app.state.memory.create_knowledge_page(
|
||||
bank_id=bank_id,
|
||||
name=body.name,
|
||||
source_query=body.source_query,
|
||||
content="Generating content...",
|
||||
parent_id=body.parent_id,
|
||||
tags=body.tags if body.tags else None,
|
||||
max_tokens=body.max_tokens,
|
||||
trigger=body.trigger.model_dump() if body.trigger else None,
|
||||
request_context=request_context,
|
||||
)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=409, detail=f"A page named '{body.name}' already exists in this folder")
|
||||
result = await app.state.memory.submit_async_refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=node["mental_model_id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
return CreateKnowledgePageResponse(
|
||||
page_id=node["id"],
|
||||
mental_model_id=node["mental_model_id"],
|
||||
operation_id=result["operation_id"],
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in POST /v1/default/banks/{bank_id}/knowledge-base/pages: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/knowledge-base/graph",
|
||||
response_model=KnowledgePageGraphResponse,
|
||||
summary="Knowledge-base constellation graph",
|
||||
description="Return pages as nodes linked by shared tags, for the constellation view.",
|
||||
operation_id="get_knowledge_base_graph",
|
||||
tags=["Knowledge Base"],
|
||||
)
|
||||
async def api_knowledge_base_graph(
|
||||
bank_id: str,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Return the shared-tag constellation graph for a bank's pages."""
|
||||
try:
|
||||
nodes = await app.state.memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
|
||||
pages = [n for n in nodes if n.get("kind") == "page"]
|
||||
# Cluster the constellation by parent folder (the knowledge base's own
|
||||
# structure) rather than by the retired type: tag.
|
||||
folder_names = {n["id"]: n["name"] for n in nodes if n.get("kind") == "folder"}
|
||||
graph = okf.knowledge_graph(pages, cluster_for=lambda p: folder_names.get(p.get("parent_id"), "Ungrouped"))
|
||||
return KnowledgePageGraphResponse(
|
||||
nodes=graph.nodes,
|
||||
edges=graph.edges,
|
||||
total_pages=len(graph.nodes),
|
||||
total_edges=len(graph.edges),
|
||||
)
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/graph: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/knowledge-base/export",
|
||||
response_model=KnowledgePageBundleResponse,
|
||||
summary="Export the knowledge base as an OKF bundle",
|
||||
description="Return a portable OKF bundle: a nested index.md, one <id>.md per page, and history logs.",
|
||||
operation_id="export_knowledge_base",
|
||||
tags=["Knowledge Base"],
|
||||
)
|
||||
async def api_export_knowledge_base(
|
||||
bank_id: str,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Export a bank's knowledge base as a flat OKF markdown bundle."""
|
||||
try:
|
||||
nodes = await app.state.memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
|
||||
files = [KnowledgePageBundleFile(path=okf.INDEX_FILENAME, content=okf.render_index(nodes))]
|
||||
for node in nodes:
|
||||
if node.get("kind") != "page":
|
||||
continue
|
||||
page = await app.state.memory.get_knowledge_page(
|
||||
bank_id=bank_id, page_id=node["id"], request_context=request_context
|
||||
)
|
||||
if page is None:
|
||||
continue
|
||||
files.append(
|
||||
KnowledgePageBundleFile(path=okf.page_filename(node["id"]), content=okf.render_document(page))
|
||||
)
|
||||
if node.get("mental_model_id"):
|
||||
history = (
|
||||
await app.state.memory.get_mental_model_history(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=node["mental_model_id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
or []
|
||||
)
|
||||
if history:
|
||||
files.append(
|
||||
KnowledgePageBundleFile(
|
||||
path=okf.log_filename(node["id"]), content=okf.render_log(page, history)
|
||||
)
|
||||
)
|
||||
return KnowledgePageBundleResponse(files=files)
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/export: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}",
|
||||
response_model=KnowledgePageResponse,
|
||||
summary="Get a knowledge-base page",
|
||||
description="Return a single page as an OKF document (frontmatter + markdown body).",
|
||||
operation_id="get_knowledge_page",
|
||||
tags=["Knowledge Base"],
|
||||
)
|
||||
async def api_get_knowledge_page(
|
||||
bank_id: str,
|
||||
page_id: str,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Get a single page as an OKF document."""
|
||||
try:
|
||||
node = await app.state.memory.get_knowledge_page(
|
||||
bank_id=bank_id, page_id=page_id, request_context=request_context
|
||||
)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=404, detail=f"Knowledge page '{page_id}' not found")
|
||||
return _knowledge_page_response(node)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in GET /v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.patch(
|
||||
"/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}",
|
||||
response_model=KnowledgeNode,
|
||||
summary="Rename or move a knowledge-base node",
|
||||
description="Rename a node (set `name`) and/or move it under another folder (set `parent_id`, "
|
||||
"null for the root).",
|
||||
operation_id="update_knowledge_node",
|
||||
tags=["Knowledge Base"],
|
||||
)
|
||||
async def api_update_knowledge_node(
|
||||
bank_id: str,
|
||||
node_id: str,
|
||||
body: UpdateNodeRequest,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Rename and/or move a node."""
|
||||
try:
|
||||
updated: dict[str, Any] | None = None
|
||||
did_change = False
|
||||
if body.name is not None:
|
||||
did_change = True
|
||||
updated = await app.state.memory.rename_knowledge_node(
|
||||
bank_id=bank_id, node_id=node_id, name=body.name, request_context=request_context
|
||||
)
|
||||
# parent_id is applied only when present in the body, so passing null
|
||||
# moves the node to the root (distinct from "not provided").
|
||||
if "parent_id" in body.model_fields_set:
|
||||
did_change = True
|
||||
updated = await app.state.memory.move_knowledge_node(
|
||||
bank_id=bank_id, node_id=node_id, new_parent_id=body.parent_id, request_context=request_context
|
||||
)
|
||||
if not did_change:
|
||||
raise HTTPException(status_code=400, detail="Provide name and/or parent_id to update")
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail=f"Knowledge node '{node_id}' not found")
|
||||
return _knowledge_node_model(updated)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.delete(
|
||||
"/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}",
|
||||
summary="Delete a knowledge-base node",
|
||||
description="Delete a folder or page and its whole subtree (pages' mental models are removed too).",
|
||||
operation_id="delete_knowledge_node",
|
||||
tags=["Knowledge Base"],
|
||||
)
|
||||
async def api_delete_knowledge_node(
|
||||
bank_id: str,
|
||||
node_id: str,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Delete a node and its subtree."""
|
||||
try:
|
||||
deleted = await app.state.memory.delete_knowledge_node(
|
||||
bank_id=bank_id, node_id=node_id, request_context=request_context
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Knowledge node '{node_id}' not found")
|
||||
return {"status": "deleted"}
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# =========================================================================
|
||||
# DIRECTIVES ENDPOINTS
|
||||
# =========================================================================
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
"""Open Knowledge Format (OKF) projection for knowledge pages.
|
||||
|
||||
Knowledge pages are a *read-only* OKF view over the existing mental models: each
|
||||
mental model is projected into an OKF document — a markdown body with YAML
|
||||
frontmatter (``type`` required; ``title``/``description``/``tags``/``timestamp``
|
||||
optional) — and pages are linked into a constellation graph via shared tags.
|
||||
|
||||
See the Open Knowledge Format spec:
|
||||
https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf
|
||||
|
||||
This module is intentionally pure: every function transforms the mental-model
|
||||
dicts returned by ``MemoryEngine.list_mental_models`` / ``get_mental_model`` and
|
||||
never touches the database. That keeps the OKF contract unit-testable without a
|
||||
DB or LLM and lets the HTTP layer stay a thin wrapper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# OKF requires exactly one frontmatter field — ``type``. We default to this when
|
||||
# a page does not declare one via a ``type:<x>`` tag.
|
||||
DEFAULT_PAGE_TYPE = "knowledge-page"
|
||||
|
||||
# A page declares its OKF ``type`` through a tag of the form ``type:runbook``.
|
||||
# This keeps the projection schema-free (no new mental_models column): the type
|
||||
# is lifted from the existing tags array.
|
||||
TYPE_TAG_PREFIX = "type:"
|
||||
|
||||
INDEX_FILENAME = "index.md"
|
||||
|
||||
# Deterministic, colour-blind-friendly palette. Type → colour is stable across
|
||||
# requests so the constellation keeps the same colours between reloads.
|
||||
_PALETTE = (
|
||||
"#0074d9", # blue
|
||||
"#2ecc40", # green
|
||||
"#b10dc9", # purple
|
||||
"#ff851b", # orange
|
||||
"#39cccc", # teal
|
||||
"#f012be", # magenta
|
||||
"#3d9970", # olive
|
||||
"#ff4136", # red
|
||||
)
|
||||
|
||||
_EDGE_COLOR = "#9aa5b1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageType:
|
||||
"""A page's OKF ``type`` and the tags that remain after the type tag is split off."""
|
||||
|
||||
type: str
|
||||
display_tags: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnowledgeGraph:
|
||||
"""Cytoscape-style node/edge graph of knowledge pages linked by shared tags."""
|
||||
|
||||
nodes: list[dict[str, Any]] = field(default_factory=list)
|
||||
edges: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _color_for(key: str) -> str:
|
||||
"""Stable colour for a string key (FNV-ish hash into the fixed palette)."""
|
||||
h = 0
|
||||
for ch in key:
|
||||
h = (h * 31 + ord(ch)) & 0xFFFFFFFF
|
||||
return _PALETTE[h % len(_PALETTE)]
|
||||
|
||||
|
||||
def _scalar(value: Any) -> str:
|
||||
"""Emit a YAML-safe double-quoted scalar.
|
||||
|
||||
We always double-quote so arbitrary page names / source queries can't be
|
||||
misread as YAML special forms (``true``, ``2026-01-01``, ``- x``, etc.).
|
||||
"""
|
||||
text = str(value)
|
||||
escaped = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "")
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def page_type(tags: list[str] | None) -> PageType:
|
||||
"""Split an OKF ``type`` out of the tag list.
|
||||
|
||||
The first ``type:<x>`` tag wins; all ``type:`` tags are removed from the
|
||||
returned ``display_tags`` so they don't pollute the constellation's
|
||||
shared-tag edges. Falls back to :data:`DEFAULT_PAGE_TYPE`.
|
||||
"""
|
||||
resolved = DEFAULT_PAGE_TYPE
|
||||
display: list[str] = []
|
||||
for tag in tags or []:
|
||||
if tag.startswith(TYPE_TAG_PREFIX):
|
||||
suffix = tag[len(TYPE_TAG_PREFIX) :].strip()
|
||||
if suffix and resolved == DEFAULT_PAGE_TYPE:
|
||||
resolved = suffix
|
||||
continue
|
||||
display.append(tag)
|
||||
return PageType(type=resolved, display_tags=display)
|
||||
|
||||
|
||||
def _timestamp(mm: dict[str, Any]) -> str | None:
|
||||
return mm.get("last_refreshed_at") or mm.get("created_at")
|
||||
|
||||
|
||||
def frontmatter(mm: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the ordered OKF frontmatter mapping for a mental model.
|
||||
|
||||
``None``/empty values are dropped by :func:`render_frontmatter`.
|
||||
"""
|
||||
pt = page_type(mm.get("tags"))
|
||||
return {
|
||||
"id": mm.get("id"),
|
||||
"type": pt.type,
|
||||
"title": mm.get("name"),
|
||||
"description": mm.get("source_query"),
|
||||
"tags": pt.display_tags,
|
||||
"timestamp": _timestamp(mm),
|
||||
}
|
||||
|
||||
|
||||
def render_frontmatter(fm: dict[str, Any]) -> str:
|
||||
"""Render a frontmatter mapping into a ``---`` fenced YAML block."""
|
||||
lines = ["---"]
|
||||
for key, value in fm.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
if not value:
|
||||
continue
|
||||
lines.append(f"{key}:")
|
||||
lines.extend(f" - {_scalar(item)}" for item in value)
|
||||
else:
|
||||
lines.append(f"{key}: {_scalar(value)}")
|
||||
lines.append("---")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_document(mm: dict[str, Any]) -> str:
|
||||
"""Render a full OKF document: frontmatter block + markdown body."""
|
||||
body = (mm.get("content") or "").strip()
|
||||
return f"{render_frontmatter(frontmatter(mm))}\n\n{body}\n" if body else f"{render_frontmatter(frontmatter(mm))}\n"
|
||||
|
||||
|
||||
def page_filename(page_id: str) -> str:
|
||||
"""OKF bundle filename for a page id."""
|
||||
return f"{page_id}.md"
|
||||
|
||||
|
||||
def log_filename(page_id: str) -> str:
|
||||
"""OKF reserved per-page history filename."""
|
||||
return f"{page_id}.log.md"
|
||||
|
||||
|
||||
def render_index(nodes: list[dict[str, Any]]) -> str:
|
||||
"""Render the reserved ``index.md`` — nested OKF navigation over the tree.
|
||||
|
||||
``nodes`` is the flat folder/page list (each with ``id``, ``kind``, ``name``,
|
||||
``parent_id``); folders nest their children, pages link to their ``.md``.
|
||||
"""
|
||||
fm = render_frontmatter({"type": "index", "title": "Knowledge base"})
|
||||
lines = [fm, "", "# Knowledge base", ""]
|
||||
|
||||
children: dict[Any, list[dict[str, Any]]] = {}
|
||||
for node in nodes:
|
||||
children.setdefault(node.get("parent_id"), []).append(node)
|
||||
|
||||
def walk(parent: Any, depth: int) -> None:
|
||||
ordered = sorted(children.get(parent, []), key=lambda n: (n.get("sort_order", 0), n.get("name") or ""))
|
||||
for node in ordered:
|
||||
indent = " " * depth
|
||||
if node.get("kind") == "folder":
|
||||
lines.append(f"{indent}- **{node['name']}/**")
|
||||
walk(node["id"], depth + 1)
|
||||
else:
|
||||
description = node.get("source_query") or node.get("description")
|
||||
link = f"{indent}- [{node['name']}](./{page_filename(node['id'])})"
|
||||
lines.append(f"{link} — {description}" if description else link)
|
||||
|
||||
walk(None, 0)
|
||||
if len(lines) == 4:
|
||||
lines.append("_No knowledge pages yet._")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def render_log(mm: dict[str, Any], history: list[dict[str, Any]]) -> str:
|
||||
"""Render the reserved per-page ``log.md`` from refresh history.
|
||||
|
||||
Each history entry is ``{previous_content, previous_reflect_response,
|
||||
changed_at}`` (newest first), capturing the content *before* a refresh.
|
||||
"""
|
||||
name = mm.get("name") or mm.get("id")
|
||||
fm = render_frontmatter({"type": "log", "title": f"{name} — history"})
|
||||
lines = [fm, "", f"# {name} — history", ""]
|
||||
if not history:
|
||||
lines.append("_No refresh history._")
|
||||
return "\n".join(lines) + "\n"
|
||||
for entry in history:
|
||||
changed_at = entry.get("changed_at") or "unknown"
|
||||
previous = (entry.get("previous_content") or "").strip()
|
||||
lines.append(f"## {changed_at}")
|
||||
lines.append("")
|
||||
lines.append(previous if previous else "_(empty)_")
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def knowledge_graph(
|
||||
pages: list[dict[str, Any]],
|
||||
cluster_for: "Callable[[dict[str, Any]], str] | None" = None,
|
||||
) -> KnowledgeGraph:
|
||||
"""Derive the constellation graph: pages as nodes, shared tags as edges.
|
||||
|
||||
Two pages are linked when they share at least one (non-``type:``) tag; the
|
||||
edge weight is the number of shared tags. Each node's cluster (``type`` field
|
||||
+ colour) comes from ``cluster_for(page)`` — the knowledge base groups by
|
||||
parent folder; the default groups by OKF ``type``.
|
||||
"""
|
||||
nodes: list[dict[str, Any]] = []
|
||||
tag_sets: list[tuple[str, frozenset[str]]] = []
|
||||
for mm in pages:
|
||||
page_id = mm["id"]
|
||||
pt = page_type(mm.get("tags"))
|
||||
cluster = cluster_for(mm) if cluster_for else pt.type
|
||||
tag_sets.append((page_id, frozenset(pt.display_tags)))
|
||||
nodes.append(
|
||||
{
|
||||
"data": {
|
||||
"id": page_id,
|
||||
"label": mm.get("name") or page_id,
|
||||
"type": cluster,
|
||||
"tagCount": len(pt.display_tags),
|
||||
"color": _color_for(cluster),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
edges: list[dict[str, Any]] = []
|
||||
for i in range(len(tag_sets)):
|
||||
source_id, source_tags = tag_sets[i]
|
||||
if not source_tags:
|
||||
continue
|
||||
for j in range(i + 1, len(tag_sets)):
|
||||
target_id, target_tags = tag_sets[j]
|
||||
shared = source_tags & target_tags
|
||||
if not shared:
|
||||
continue
|
||||
edges.append(
|
||||
{
|
||||
"data": {
|
||||
"id": f"{source_id}--{target_id}",
|
||||
"source": source_id,
|
||||
"target": target_id,
|
||||
"sharedTags": sorted(shared),
|
||||
"weight": len(shared),
|
||||
"color": _EDGE_COLOR,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return KnowledgeGraph(nodes=nodes, edges=edges)
|
||||
@@ -638,6 +638,7 @@ ENV_RECALL_BUDGET_MAX = "HINDSIGHT_API_RECALL_BUDGET_MAX"
|
||||
|
||||
# Recall candidate gating (per-source cap + BM25 score floor)
|
||||
ENV_BM25_MIN_SCORE = "HINDSIGHT_API_BM25_MIN_SCORE"
|
||||
ENV_BM25_MAX_QUERY_TERMS = "HINDSIGHT_API_BM25_MAX_QUERY_TERMS"
|
||||
ENV_RECALL_MAX_CANDIDATES_PER_SOURCE = "HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE"
|
||||
# Per-strategy recall boost. Prioritises specific retrieval arms (semantic,
|
||||
# bm25, graph, temporal) on recall via a human priority level — e.g.
|
||||
@@ -789,6 +790,9 @@ DEFAULT_SEMANTIC_MIN_SIMILARITY = 0.3
|
||||
# zero-score (non-matching) rows on backends — notably VectorChord — whose
|
||||
# operator ranks every document rather than pre-filtering to term matches.
|
||||
DEFAULT_BM25_MIN_SCORE = 0.0
|
||||
# Native tsvector BM25 can optionally cap the OR tsquery built from normalized
|
||||
# query tokens. 0 preserves the historical uncapped behavior.
|
||||
DEFAULT_BM25_MAX_QUERY_TERMS = 0
|
||||
# Per-source candidate cap applied to each retrieval arm (semantic, BM25, graph,
|
||||
# temporal) before RRF, so a single over-expanding backend cannot fill the
|
||||
# reranker's global candidate budget on its own. 0 disables the cap.
|
||||
@@ -1209,6 +1213,19 @@ def _parse_positive_int(name: str, raw: str | None, default: int) -> int:
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_non_negative_int(name: str, raw: str | None, default: int) -> int:
|
||||
"""Parse an env var that must be an integer >= 0."""
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
try:
|
||||
parsed = int(raw)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"{name} must be an integer, got {raw!r}") from e
|
||||
if parsed < 0:
|
||||
raise ValueError(f"{name} must be >= 0, got {parsed}")
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_optional_positive_int(name: str, raw: str | None) -> int | None:
|
||||
"""Parse an optional env var that must be a positive integer when set."""
|
||||
if raw is None or raw == "":
|
||||
@@ -1979,6 +1996,7 @@ class HindsightConfig:
|
||||
reflect_llm_strategy: LLMStrategyConfig | None = None
|
||||
consolidation_llm_members: list[LLMMemberConfig] = field(default_factory=list)
|
||||
consolidation_llm_strategy: LLMStrategyConfig | None = None
|
||||
bm25_max_query_terms: int = DEFAULT_BM25_MAX_QUERY_TERMS
|
||||
|
||||
# Class-level sets for configuration categorization
|
||||
|
||||
@@ -2179,6 +2197,9 @@ class HindsightConfig:
|
||||
f"Invalid semantic_min_similarity: {self.semantic_min_similarity}. Must be between 0.0 and 1.0"
|
||||
)
|
||||
|
||||
if self.bm25_max_query_terms < 0:
|
||||
raise ValueError(f"Invalid bm25_max_query_terms: {self.bm25_max_query_terms}. Must be >= 0")
|
||||
|
||||
# Validate bedrock_service_tier
|
||||
valid_bedrock_tiers = (None, "flex", "priority", "reserved")
|
||||
if self.llm_bedrock_service_tier not in valid_bedrock_tiers:
|
||||
@@ -2608,6 +2629,11 @@ class HindsightConfig:
|
||||
reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))),
|
||||
semantic_min_similarity=float(os.getenv(ENV_SEMANTIC_MIN_SIMILARITY, str(DEFAULT_SEMANTIC_MIN_SIMILARITY))),
|
||||
bm25_min_score=float(os.getenv(ENV_BM25_MIN_SCORE, str(DEFAULT_BM25_MIN_SCORE))),
|
||||
bm25_max_query_terms=_parse_non_negative_int(
|
||||
ENV_BM25_MAX_QUERY_TERMS,
|
||||
os.getenv(ENV_BM25_MAX_QUERY_TERMS),
|
||||
DEFAULT_BM25_MAX_QUERY_TERMS,
|
||||
),
|
||||
recall_max_candidates_per_source=int(
|
||||
os.getenv(ENV_RECALL_MAX_CANDIDATES_PER_SOURCE, str(DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE))
|
||||
),
|
||||
|
||||
@@ -102,6 +102,15 @@ class _DedupDecision(BaseModel):
|
||||
text: str = "" # the synthesized merged observation (when action == "merge")
|
||||
reason: str = ""
|
||||
|
||||
@field_validator("action", mode="before")
|
||||
@classmethod
|
||||
def _normalize_action(cls, value: object) -> str:
|
||||
if isinstance(value, str) and value in {"merge", "keep"}:
|
||||
return value
|
||||
|
||||
logger.warning("Invalid consolidation dedup action %r; defaulting to keep", value)
|
||||
return "keep"
|
||||
|
||||
|
||||
_DEDUP_PROMPT = """You reconcile long-term memory observations. A NEW observation is about to be \
|
||||
stored, and it is highly similar to an EXISTING one:
|
||||
|
||||
@@ -9,6 +9,33 @@ from .ops import DataAccessOps, TagListingParts
|
||||
from .result import ResultRow
|
||||
|
||||
|
||||
def pg_search_vector_expr(
|
||||
config,
|
||||
*,
|
||||
text_col: str = "text",
|
||||
context_col: str = "context",
|
||||
signals_col: str = "text_signals",
|
||||
) -> str | None:
|
||||
"""SQL expression that builds ``search_vector`` for the configured PG text-search backend.
|
||||
|
||||
Single source of truth shared by the batch insert (over the ``input_data``
|
||||
CTE columns) and the curation revert recompute (over a ``memory_units`` row),
|
||||
so the two can never drift. Returns ``None`` for backends that leave
|
||||
``search_vector`` unpopulated — pgroonga / pg_textsearch / pg_search index the
|
||||
base text columns directly and keep only a dummy column, so there is nothing
|
||||
to build.
|
||||
|
||||
``text_search_extension_native_language`` is validated as a PG identifier in
|
||||
``HindsightConfig.validate()``, so embedding it as a SQL literal is safe.
|
||||
"""
|
||||
combined = f"COALESCE({text_col}, '') || ' ' || COALESCE({context_col}, '') || ' ' || COALESCE({signals_col}, '')"
|
||||
if config.text_search_extension == "vchord":
|
||||
return f"tokenize({combined}, 'llmlingua2')::bm25_catalog.bm25vector"
|
||||
if config.text_search_extension == "native":
|
||||
return f"to_tsvector('{config.text_search_extension_native_language}'::regconfig, {combined})"
|
||||
return None
|
||||
|
||||
|
||||
class PostgreSQLOps(DataAccessOps):
|
||||
"""PostgreSQL-specific data access operations using unnest and LATERAL."""
|
||||
|
||||
@@ -93,101 +120,39 @@ class PostgreSQLOps(DataAccessOps):
|
||||
config = get_config()
|
||||
table = self._get_mu_table()
|
||||
|
||||
if config.text_search_extension == "vchord":
|
||||
query = f"""
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags_json,
|
||||
observation_scopes_json, text_signals)
|
||||
)
|
||||
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags,
|
||||
observation_scopes, text_signals, search_vector)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
),
|
||||
observation_scopes_json,
|
||||
text_signals,
|
||||
tokenize(
|
||||
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, ''),
|
||||
'llmlingua2'
|
||||
)::bm25_catalog.bm25vector
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
"""
|
||||
elif config.text_search_extension == "native":
|
||||
# search_vector is a regular tsvector column populated here using the
|
||||
# configured native dictionary. It used to be GENERATED ALWAYS with
|
||||
# a hardcoded 'english', which prevented per-deployment language
|
||||
# configuration. text_search_extension_native_language is validated
|
||||
# in HindsightConfig.validate() as a PG identifier, so embedding it
|
||||
# as a SQL literal is safe.
|
||||
query = f"""
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags_json,
|
||||
observation_scopes_json, text_signals)
|
||||
)
|
||||
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags,
|
||||
observation_scopes, text_signals, search_vector)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
),
|
||||
observation_scopes_json,
|
||||
text_signals,
|
||||
to_tsvector(
|
||||
'{config.text_search_extension_native_language}'::regconfig,
|
||||
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, '')
|
||||
)
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
"""
|
||||
else:
|
||||
# pg_textsearch, pgroonga, and pg_search: search_vector is a dummy
|
||||
# TEXT column; the actual full-text index operates on the base text
|
||||
# columns directly, so we don't populate search_vector at insert time.
|
||||
query = f"""
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags_json,
|
||||
observation_scopes_json, text_signals)
|
||||
)
|
||||
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags,
|
||||
observation_scopes, text_signals)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
),
|
||||
observation_scopes_json,
|
||||
text_signals
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
"""
|
||||
# search_vector is populated inline for backends that store a real vector
|
||||
# (native tsvector, vchord bm25vector). pgroonga / pg_textsearch / pg_search
|
||||
# index the base text columns directly and keep only a dummy column, so the
|
||||
# expression is None and the column is left out of the insert entirely.
|
||||
# Same expression is reused by curation revert (see pg_search_vector_expr).
|
||||
sv_expr = pg_search_vector_expr(config)
|
||||
sv_insert_col = ", search_vector" if sv_expr else ""
|
||||
sv_select_val = f",\n {sv_expr}" if sv_expr else ""
|
||||
query = f"""
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags_json,
|
||||
observation_scopes_json, text_signals)
|
||||
)
|
||||
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id, tags,
|
||||
observation_scopes, text_signals{sv_insert_col})
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
),
|
||||
observation_scopes_json,
|
||||
text_signals{sv_select_val}
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
results = await conn.fetch(
|
||||
query,
|
||||
|
||||
@@ -6570,13 +6570,18 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
)
|
||||
|
||||
collist = await self._memory_unit_columns(conn)
|
||||
# The archive is cold storage, never a recall surface, so the schema gives it
|
||||
# no `embedding` column at all (dropped in d4f6a8c2e1b3). The move in/out is
|
||||
# therefore over every memory_units column EXCEPT embedding; on revert the
|
||||
# embedding is recomputed from the unit's text/dates/entities below. This makes
|
||||
# a model switch (which re-dimensions memory_units) structurally unable to trip
|
||||
# a vector-dimension mismatch on the INSERT … SELECT round-trip (#2209).
|
||||
arch_cols = ", ".join(c for c in (s.strip() for s in collist.split(",")) if c != '"embedding"')
|
||||
# The archive is cold storage, never a recall surface and carries no index,
|
||||
# so the schema gives it neither the `embedding` (dropped in d4f6a8c2e1b3)
|
||||
# nor the `search_vector` column (dropped in e7c3a9f1b2d5). Both are
|
||||
# recall-surface columns whose type/shape follows server
|
||||
# config, so the move in/out is over every memory_units column EXCEPT those
|
||||
# two; on revert each is recomputed from the unit's text/dates/entities below.
|
||||
# This makes a model switch (which re-dimensions memory_units) structurally
|
||||
# unable to trip a vector-dimension mismatch (#2209), and a text-search backend
|
||||
# switch unable to trip a search_vector type mismatch (#2503), on the
|
||||
# INSERT … SELECT round-trip.
|
||||
_archive_omitted = ('"embedding"', '"search_vector"')
|
||||
arch_cols = ", ".join(c for c in (s.strip() for s in collist.split(",")) if c not in _archive_omitted)
|
||||
|
||||
# --- Edit fields (live rows only): text / context / dates / fact_type / entities ---
|
||||
doing_edit = any(
|
||||
@@ -6634,6 +6639,17 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
mentioned_at=live["mentioned_at"],
|
||||
entities=[r["canonical_name"] for r in ent_rows],
|
||||
)
|
||||
# Keep the stored text-search vector in sync with curated
|
||||
# text/context edits. Use the incoming parameters here:
|
||||
# PostgreSQL evaluates UPDATE RHS expressions before the
|
||||
# sibling SET assignments take effect, so column references
|
||||
# would see the pre-edit text/context.
|
||||
from .db.ops_postgresql import pg_search_vector_expr
|
||||
|
||||
sv_expr = pg_search_vector_expr(get_config(), text_col="$3", context_col="$4")
|
||||
search_vector_clause = (
|
||||
f",\n search_vector = {sv_expr}" if sv_expr else ""
|
||||
)
|
||||
await enqueue_relink_victims(conn, bank_id, [memory_id], ops=backend.ops)
|
||||
await conn.execute(
|
||||
f"""
|
||||
@@ -6641,7 +6657,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
SET text = $3, context = $4, fact_type = $5, occurred_start = $6,
|
||||
occurred_end = $7, event_date = $8, embedding = $9::vector,
|
||||
consolidated_at = NULL, consolidation_failed_at = NULL,
|
||||
edited_at = now(), updated_at = now()
|
||||
edited_at = now(), updated_at = now(){search_vector_clause}
|
||||
WHERE id = $1 AND bank_id = $2
|
||||
""",
|
||||
str(memory_uuid),
|
||||
@@ -6695,14 +6711,29 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
arch_row = await conn.fetchrow(
|
||||
f"SELECT entity_ids FROM {arch} WHERE id = $1 AND bank_id = $2", str(memory_uuid), bank_id
|
||||
)
|
||||
# The archive has no embedding column (see arch_cols above), so the live
|
||||
# row's embedding defaults to NULL on the way back and is recomputed below
|
||||
# once entities are restored.
|
||||
# The archive keeps neither embedding nor search_vector (see arch_cols
|
||||
# above), so both default to NULL on the way back and are recomputed here:
|
||||
# the embedding below once entities are restored, the search_vector now
|
||||
# from the row's own text/context/text_signals.
|
||||
await conn.execute(
|
||||
f"INSERT INTO {mu} ({arch_cols}) SELECT {arch_cols} FROM {arch} WHERE id = $1 AND bank_id = $2",
|
||||
str(memory_uuid),
|
||||
bank_id,
|
||||
)
|
||||
# Rebuild search_vector using the *current* text-search backend, so the
|
||||
# reverted unit is keyword-searchable again (more correct than carrying a
|
||||
# verbatim copy that could be stale/wrong-type if the backend changed while
|
||||
# the fact sat archived). None = pgroonga/pg_textsearch/pg_search, which
|
||||
# index base columns directly and leave search_vector empty (#2503).
|
||||
from .db.ops_postgresql import pg_search_vector_expr
|
||||
|
||||
sv_expr = pg_search_vector_expr(get_config())
|
||||
if sv_expr is not None:
|
||||
await conn.execute(
|
||||
f"UPDATE {mu} SET search_vector = {sv_expr} WHERE id = $1 AND bank_id = $2",
|
||||
str(memory_uuid),
|
||||
bank_id,
|
||||
)
|
||||
# Re-consolidate from scratch; links are rebuilt by graph maintenance.
|
||||
await conn.execute(
|
||||
f"UPDATE {mu} SET consolidated_at = NULL, consolidation_failed_at = NULL, updated_at = now() "
|
||||
@@ -7446,7 +7477,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
f"""
|
||||
SELECT id, text, event_date, context, fact_type, document_id,
|
||||
mentioned_at, occurred_start, occurred_end, chunk_id, proof_count,
|
||||
tags, consolidated_at, consolidation_failed_at, edited_at, {curation_cols}
|
||||
tags, metadata, consolidated_at, consolidation_failed_at, edited_at, {curation_cols}
|
||||
FROM {source_table}
|
||||
{where_clause}
|
||||
ORDER BY mentioned_at DESC NULLS LAST, created_at DESC
|
||||
@@ -7501,6 +7532,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"chunk_id": row["chunk_id"] if row["chunk_id"] else None,
|
||||
"proof_count": row["proof_count"] if row["proof_count"] is not None else 1,
|
||||
"tags": list(row["tags"]) if row["tags"] else [],
|
||||
"metadata": conn.parse_json(row["metadata"]) if row["metadata"] is not None else {},
|
||||
"consolidated_at": row["consolidated_at"].isoformat() if row["consolidated_at"] else None,
|
||||
"consolidation_failed_at": (
|
||||
row["consolidation_failed_at"].isoformat() if row["consolidation_failed_at"] else None
|
||||
@@ -7553,7 +7585,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# back to the archive (with its invalidation bookkeeping) on a miss.
|
||||
select_cols = (
|
||||
"id, text, context, event_date, occurred_start, occurred_end, "
|
||||
"mentioned_at, fact_type, document_id, chunk_id, tags, source_memory_ids, "
|
||||
"mentioned_at, fact_type, document_id, chunk_id, tags, metadata, source_memory_ids, "
|
||||
"observation_scopes, edited_at"
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
@@ -7597,6 +7629,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"document_id": row["document_id"] if row["document_id"] else None,
|
||||
"chunk_id": str(row["chunk_id"]) if row["chunk_id"] else None,
|
||||
"tags": row["tags"] if row["tags"] else [],
|
||||
"metadata": conn.parse_json(row["metadata"]) if row["metadata"] is not None else {},
|
||||
"observation_scopes": row["observation_scopes"] if row["observation_scopes"] else None,
|
||||
"state": unit_state,
|
||||
"invalidation_reason": row["invalidation_reason"],
|
||||
@@ -11119,335 +11152,6 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
return result == "DELETE 1"
|
||||
|
||||
# =====================================================================
|
||||
# KNOWLEDGE BASE (folders + pages over mental models)
|
||||
# =====================================================================
|
||||
# The knowledge base is a tree of folders and pages stored in
|
||||
# ``knowledge_pages``. A page references the mental model holding its content
|
||||
# (``mental_model_id``); a folder is a container (``mental_model_id`` NULL).
|
||||
# Content lives in ``mental_models`` — this layer owns only tree structure.
|
||||
|
||||
# Default trigger for a knowledge page: a living document synthesized from the
|
||||
# bank's consolidated **observations** (not raw facts), refreshed incrementally
|
||||
# (delta) after each consolidation, and excluding other mental models so a page
|
||||
# never reflects on sibling pages. Applied when the client doesn't pass its own
|
||||
# ``trigger`` on create; a client can override any of these.
|
||||
KNOWLEDGE_PAGE_DEFAULT_TRIGGER = {
|
||||
"mode": "delta",
|
||||
"fact_types": ["observation"],
|
||||
"exclude_mental_models": True,
|
||||
"refresh_after_consolidation": True,
|
||||
}
|
||||
|
||||
# Knowledge pages default to a larger budget than a plain mental model (2048)
|
||||
# since they're meant to read as full documents. Applied when the client
|
||||
# doesn't pass ``max_tokens`` on create.
|
||||
KNOWLEDGE_PAGE_DEFAULT_MAX_TOKENS = 4096
|
||||
|
||||
@staticmethod
|
||||
def _row_to_knowledge_node(row) -> dict[str, Any]:
|
||||
"""Project a knowledge_pages row (optionally joined to its mental model)."""
|
||||
node: dict[str, Any] = {
|
||||
"id": row["id"],
|
||||
"bank_id": row["bank_id"],
|
||||
"parent_id": row["parent_id"],
|
||||
"kind": row["kind"],
|
||||
"name": row["name"],
|
||||
"mental_model_id": row["mental_model_id"],
|
||||
"sort_order": row["sort_order"],
|
||||
"managed": (row["managed"] if "managed" in row else False),
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||
}
|
||||
# Page rows are returned LEFT JOINed to mental_models so the OKF
|
||||
# projection (type/tags/description) needs no second round-trip.
|
||||
if "mm_tags" in row:
|
||||
node["tags"] = list(row["mm_tags"] or [])
|
||||
node["source_query"] = row["mm_source_query"]
|
||||
node["last_refreshed_at"] = row["mm_last_refreshed_at"].isoformat() if row["mm_last_refreshed_at"] else None
|
||||
return node
|
||||
|
||||
# Column list for plain (non-joined) knowledge_pages reads/RETURNING.
|
||||
_KP_COLUMNS = "id, bank_id, parent_id, kind, name, mental_model_id, sort_order, managed, created_at, updated_at"
|
||||
|
||||
_KP_PAGE_SELECT = (
|
||||
"kp.id, kp.bank_id, kp.parent_id, kp.kind, kp.name, kp.mental_model_id, "
|
||||
"kp.sort_order, kp.managed, kp.created_at, kp.updated_at, "
|
||||
"mm.tags AS mm_tags, mm.source_query AS mm_source_query, "
|
||||
"mm.last_refreshed_at AS mm_last_refreshed_at"
|
||||
)
|
||||
|
||||
def _kp_join(self) -> str:
|
||||
kp = fq_table("knowledge_pages")
|
||||
mm = fq_table("mental_models")
|
||||
return f"{kp} kp LEFT JOIN {mm} mm ON mm.id = kp.mental_model_id AND mm.bank_id = kp.bank_id"
|
||||
|
||||
async def _kp_assert_folder_parent(self, conn, bank_id: str, parent_id: str | None) -> None:
|
||||
"""A non-null parent must be an existing folder in this bank."""
|
||||
if parent_id is None:
|
||||
return
|
||||
row = await conn.fetchrow(
|
||||
f"SELECT kind FROM {fq_table('knowledge_pages')} WHERE bank_id = $1 AND id = $2",
|
||||
bank_id,
|
||||
parent_id,
|
||||
)
|
||||
if row is None:
|
||||
raise ValueError(f"Parent folder '{parent_id}' not found")
|
||||
if row["kind"] != "folder":
|
||||
raise ValueError(f"Parent '{parent_id}' is not a folder")
|
||||
|
||||
async def create_knowledge_folder(
|
||||
self,
|
||||
bank_id: str,
|
||||
name: str,
|
||||
*,
|
||||
parent_id: str | None = None,
|
||||
managed: bool = False,
|
||||
request_context: "RequestContext",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a folder (a container node) in the knowledge base.
|
||||
|
||||
The knowledge base is managed by clients (CRUD over folders/pages);
|
||||
``managed`` lets a client tag a node as system-owned vs. hand-authored.
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
backend = await self._get_backend()
|
||||
folder_id = f"kf-{uuid.uuid4().hex}"
|
||||
async with acquire_with_retry(backend) as conn:
|
||||
async with conn.transaction():
|
||||
await self._ensure_bank_exists(bank_id, request_context, conn=conn)
|
||||
await self._kp_assert_folder_parent(conn, bank_id, parent_id)
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO {fq_table("knowledge_pages")} (id, bank_id, parent_id, kind, name, managed)
|
||||
VALUES ($1, $2, $3, 'folder', $4, $5)
|
||||
RETURNING {self._KP_COLUMNS}
|
||||
""",
|
||||
folder_id,
|
||||
bank_id,
|
||||
parent_id,
|
||||
name,
|
||||
managed,
|
||||
)
|
||||
return self._row_to_knowledge_node(row)
|
||||
|
||||
async def create_knowledge_page(
|
||||
self,
|
||||
bank_id: str,
|
||||
name: str,
|
||||
source_query: str,
|
||||
content: str,
|
||||
*,
|
||||
parent_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
max_tokens: int | None = None,
|
||||
trigger: dict[str, Any] | None = None,
|
||||
mental_model_id: str | None = None,
|
||||
managed: bool = False,
|
||||
request_context: "RequestContext",
|
||||
) -> dict[str, Any] | None:
|
||||
"""Create a page: a backing mental model plus the tree node that refs it.
|
||||
|
||||
``managed`` lets a client tag the page as system-owned vs. hand-authored.
|
||||
When ``trigger`` is omitted the page uses ``KNOWLEDGE_PAGE_DEFAULT_TRIGGER``
|
||||
(observation-only, delta, auto-refresh) so a knowledge page is a living
|
||||
document by default.
|
||||
|
||||
Returns ``None`` when a page with the same name already exists in the same
|
||||
folder (a uniqueness violation) — the caller should treat that as
|
||||
"already exists" (surfaced by the API as a 409).
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
# The mental model carries the content (and is created+validated by the
|
||||
# existing path, including lazy bank creation); the node only refs it.
|
||||
mm = await self.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name=name,
|
||||
source_query=source_query,
|
||||
content=content,
|
||||
mental_model_id=mental_model_id,
|
||||
tags=tags,
|
||||
max_tokens=max_tokens if max_tokens is not None else self.KNOWLEDGE_PAGE_DEFAULT_MAX_TOKENS,
|
||||
trigger=trigger if trigger is not None else dict(self.KNOWLEDGE_PAGE_DEFAULT_TRIGGER),
|
||||
request_context=request_context,
|
||||
)
|
||||
backend = await self._get_backend()
|
||||
page_id = f"kp-{uuid.uuid4().hex}"
|
||||
try:
|
||||
async with acquire_with_retry(backend) as conn:
|
||||
async with conn.transaction():
|
||||
await self._kp_assert_folder_parent(conn, bank_id, parent_id)
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO {fq_table("knowledge_pages")}
|
||||
(id, bank_id, parent_id, kind, name, mental_model_id, managed)
|
||||
VALUES ($1, $2, $3, 'page', $4, $5, $6)
|
||||
RETURNING {self._KP_COLUMNS}
|
||||
""",
|
||||
page_id,
|
||||
bank_id,
|
||||
parent_id,
|
||||
name,
|
||||
mm["id"],
|
||||
managed,
|
||||
)
|
||||
except asyncpg.UniqueViolationError:
|
||||
# Duplicate page name in this folder (uq_kp_folder_pagename). Roll back
|
||||
# by deleting the orphan mental model we just created, then signal the
|
||||
# caller that the page already exists.
|
||||
await self.delete_mental_model(bank_id, mm["id"], request_context=request_context)
|
||||
return None
|
||||
node = self._row_to_knowledge_node(row)
|
||||
# Surface the mental-model metadata so the caller can render OKF or
|
||||
# schedule a content refresh without a second fetch.
|
||||
node["tags"] = list(mm.get("tags") or [])
|
||||
node["source_query"] = mm.get("source_query")
|
||||
node["last_refreshed_at"] = mm.get("last_refreshed_at")
|
||||
return node
|
||||
|
||||
async def list_knowledge_nodes(self, bank_id: str, *, request_context: "RequestContext") -> list[dict[str, Any]]:
|
||||
"""Return every folder/page node in the bank (flat; caller builds the tree)."""
|
||||
await self._authenticate_tenant(request_context)
|
||||
backend = await self._get_backend()
|
||||
async with acquire_with_retry(backend) as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {self._KP_PAGE_SELECT}
|
||||
FROM {self._kp_join()}
|
||||
WHERE kp.bank_id = $1
|
||||
ORDER BY kp.sort_order, kp.name
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
return [self._row_to_knowledge_node(r) for r in rows]
|
||||
|
||||
async def get_knowledge_page(
|
||||
self, bank_id: str, page_id: str, *, request_context: "RequestContext"
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a page node merged with its mental model's content (for OKF)."""
|
||||
await self._authenticate_tenant(request_context)
|
||||
backend = await self._get_backend()
|
||||
async with acquire_with_retry(backend) as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT {self._KP_PAGE_SELECT}, mm.content AS mm_content
|
||||
FROM {self._kp_join()}
|
||||
WHERE kp.bank_id = $1 AND kp.id = $2 AND kp.kind = 'page'
|
||||
""",
|
||||
bank_id,
|
||||
page_id,
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
node = self._row_to_knowledge_node(row)
|
||||
node["content"] = row["mm_content"]
|
||||
return node
|
||||
|
||||
async def rename_knowledge_node(
|
||||
self, bank_id: str, node_id: str, name: str, *, request_context: "RequestContext"
|
||||
) -> dict[str, Any] | None:
|
||||
"""Rename a folder or page node."""
|
||||
await self._authenticate_tenant(request_context)
|
||||
backend = await self._get_backend()
|
||||
async with acquire_with_retry(backend) as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
UPDATE {fq_table("knowledge_pages")}
|
||||
SET name = $3, updated_at = now()
|
||||
WHERE bank_id = $1 AND id = $2
|
||||
RETURNING {self._KP_COLUMNS}
|
||||
""",
|
||||
bank_id,
|
||||
node_id,
|
||||
name,
|
||||
)
|
||||
return self._row_to_knowledge_node(row) if row else None
|
||||
|
||||
async def move_knowledge_node(
|
||||
self, bank_id: str, node_id: str, new_parent_id: str | None, *, request_context: "RequestContext"
|
||||
) -> dict[str, Any] | None:
|
||||
"""Re-parent a node, rejecting self-parenting and cycles."""
|
||||
await self._authenticate_tenant(request_context)
|
||||
if new_parent_id == node_id:
|
||||
raise ValueError("A node cannot be its own parent")
|
||||
backend = await self._get_backend()
|
||||
async with acquire_with_retry(backend) as conn:
|
||||
async with conn.transaction():
|
||||
await self._kp_assert_folder_parent(conn, bank_id, new_parent_id)
|
||||
# Cycle guard: walk up from the new parent; if we reach node_id,
|
||||
# the move would create a loop. Done in Python so the check stays
|
||||
# dialect-agnostic (no recursive CTE).
|
||||
if new_parent_id is not None:
|
||||
parents = {
|
||||
r["id"]: r["parent_id"]
|
||||
for r in await conn.fetch(
|
||||
f"SELECT id, parent_id FROM {fq_table('knowledge_pages')} WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
}
|
||||
cursor: str | None = new_parent_id
|
||||
while cursor is not None:
|
||||
if cursor == node_id:
|
||||
raise ValueError("Cannot move a node into its own subtree")
|
||||
cursor = parents.get(cursor)
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
UPDATE {fq_table("knowledge_pages")}
|
||||
SET parent_id = $3, updated_at = now()
|
||||
WHERE bank_id = $1 AND id = $2
|
||||
RETURNING {self._KP_COLUMNS}
|
||||
""",
|
||||
bank_id,
|
||||
node_id,
|
||||
new_parent_id,
|
||||
)
|
||||
return self._row_to_knowledge_node(row) if row else None
|
||||
|
||||
async def delete_knowledge_node(self, bank_id: str, node_id: str, *, request_context: "RequestContext") -> bool:
|
||||
"""Delete a node and its whole subtree, including each page's mental model.
|
||||
|
||||
Deleting the mental models cascades their page rows away (FK ON DELETE
|
||||
CASCADE); deleting the node then cascades any remaining descendant folder
|
||||
rows. The subtree is gathered in Python so the logic is dialect-agnostic.
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
backend = await self._get_backend()
|
||||
async with acquire_with_retry(backend) as conn:
|
||||
async with conn.transaction():
|
||||
all_rows = await conn.fetch(
|
||||
f"SELECT id, parent_id, mental_model_id FROM {fq_table('knowledge_pages')} WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
by_parent: dict[str | None, list] = {}
|
||||
for r in all_rows:
|
||||
by_parent.setdefault(r["parent_id"], []).append(r)
|
||||
if not any(r["id"] == node_id for r in all_rows):
|
||||
return False
|
||||
# BFS the subtree rooted at node_id, collecting page mental models.
|
||||
stack = [node_id]
|
||||
mm_ids: list[str] = []
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
for child in by_parent.get(current, []):
|
||||
stack.append(child["id"])
|
||||
node_row = next((r for r in all_rows if r["id"] == current), None)
|
||||
if node_row and node_row["mental_model_id"]:
|
||||
mm_ids.append(node_row["mental_model_id"])
|
||||
# Delete each backing mental model individually (the subtree is
|
||||
# small) to keep the SQL dialect-neutral — no PG array casts.
|
||||
for mm_id in mm_ids:
|
||||
await conn.execute(
|
||||
f"DELETE FROM {fq_table('mental_models')} WHERE bank_id = $1 AND id = $2",
|
||||
bank_id,
|
||||
mm_id,
|
||||
)
|
||||
await conn.execute(
|
||||
f"DELETE FROM {fq_table('knowledge_pages')} WHERE bank_id = $1 AND id = $2",
|
||||
bank_id,
|
||||
node_id,
|
||||
)
|
||||
return True
|
||||
|
||||
async def compute_mental_model_is_stale(
|
||||
self,
|
||||
conn,
|
||||
@@ -13176,4 +12880,3 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
result_metadata={"mental_model_id": mental_model_id, "name": mental_model["name"]},
|
||||
dedupe_by_bank=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -186,7 +186,11 @@ class MarkitdownParser(FileParser):
|
||||
if Path(filename).suffix.lower() not in _TEXT_EXTENSIONS:
|
||||
return None
|
||||
try:
|
||||
file_data.decode("utf-8")
|
||||
# file_data may arrive as a non-``bytes`` buffer (e.g. a memoryview or
|
||||
# a native/Rust-backed buffer object) that has no ``.decode``; coerce
|
||||
# through the buffer protocol before the UTF-8 probe. The ``tmp.write``
|
||||
# in the caller already relies only on the same buffer protocol.
|
||||
bytes(file_data).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
from markitdown import StreamInfo
|
||||
|
||||
@@ -53,6 +53,53 @@ __all__ = [
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Name of the single forced function tool used to carry structured output when
|
||||
# strict_schema is on. The Codex backend speaks the OpenAI Responses API, so a
|
||||
# forced function call gives us constrained decoding straight into the response
|
||||
# schema — no prompt-injected schema, no raw json.loads on free-form model text,
|
||||
# no invalid-\escape retry storm (issue #2504, same class as #1002 / #2339).
|
||||
_STRUCTURED_TOOL_NAME = "structured_response"
|
||||
|
||||
# Valid JSON string escape characters (the char that may follow a backslash).
|
||||
_VALID_JSON_ESCAPE_CHARS = set('"\\/bfnrtu')
|
||||
|
||||
|
||||
def _repair_invalid_json_escapes(text: str) -> str:
|
||||
"""Best-effort repair of invalid ``\\escape`` sequences in a JSON string.
|
||||
|
||||
Escape-heavy content (code, serial/CLI commands, Windows paths, regexes)
|
||||
makes weaker models emit backslashes that aren't valid JSON escapes (e.g.
|
||||
``\\d``, ``\\s``, ``C:\\Users``), so ``json.loads`` fails deterministically
|
||||
and every retry re-fails the same way (issue #2504). This doubles any
|
||||
backslash that isn't part of a valid escape so the payload parses. It is a
|
||||
lenient fallback only — the strict_schema forced-tool path is the real fix.
|
||||
"""
|
||||
result: list[str] = []
|
||||
i = 0
|
||||
n = len(text)
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
if ch == "\\" and i + 1 < n:
|
||||
nxt = text[i + 1]
|
||||
if nxt in _VALID_JSON_ESCAPE_CHARS:
|
||||
# Preserve the valid escape (both chars) verbatim.
|
||||
result.append(ch)
|
||||
result.append(nxt)
|
||||
i += 2
|
||||
continue
|
||||
# Invalid escape: escape the lone backslash so JSON parses.
|
||||
result.append("\\\\")
|
||||
i += 1
|
||||
continue
|
||||
if ch == "\\" and i + 1 == n:
|
||||
# Trailing lone backslash — escape it.
|
||||
result.append("\\\\")
|
||||
i += 1
|
||||
continue
|
||||
result.append(ch)
|
||||
i += 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
class CodexLLM(LLMInterface):
|
||||
"""
|
||||
@@ -336,7 +383,18 @@ class CodexLLM(LLMInterface):
|
||||
strict_schema: bool = False,
|
||||
return_usage: bool = False,
|
||||
) -> Any:
|
||||
"""Make API call to Codex backend with SSE streaming."""
|
||||
"""Make API call to Codex backend with SSE streaming.
|
||||
|
||||
Args:
|
||||
strict_schema: Route structured output through a single forced
|
||||
function tool (constrained decoding) instead of prompt-injecting
|
||||
the schema and parsing free-form text. The Codex backend speaks
|
||||
the OpenAI Responses API, so the forced function call emits the
|
||||
response schema directly as tool arguments — eliminating the
|
||||
invalid-``\\escape`` retry storm (issue #2504). When False, falls
|
||||
back to schema-in-prompt + JSON parse, now hardened with a lenient
|
||||
invalid-escape repair before giving up.
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Proactively refresh the OAuth access_token if it's near expiry.
|
||||
@@ -361,11 +419,22 @@ class CodexLLM(LLMInterface):
|
||||
else:
|
||||
user_messages.append(msg)
|
||||
|
||||
# Add JSON schema instruction if response_format is provided
|
||||
# Structured output: prefer a single forced function tool (constrained
|
||||
# decoding) over text-injecting the schema and parsing the reply. The
|
||||
# forced tool guarantees schema-shaped JSON in the tool arguments,
|
||||
# eliminating the invalid-\escape retry storm (issue #2504). When
|
||||
# strict_schema is off we keep the schema-in-prompt + json.loads
|
||||
# fallback (now hardened with a lenient escape repair) for callers that
|
||||
# can't force tools.
|
||||
schema = None
|
||||
use_forced_tool = False
|
||||
if response_format is not None and hasattr(response_format, "model_json_schema"):
|
||||
schema = response_format.model_json_schema()
|
||||
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
|
||||
system_instruction += schema_msg
|
||||
if strict_schema:
|
||||
use_forced_tool = True
|
||||
else:
|
||||
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
|
||||
system_instruction += schema_msg
|
||||
|
||||
# gpt-5.2-codex only supports "detailed" reasoning summary
|
||||
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
|
||||
@@ -392,6 +461,20 @@ class CodexLLM(LLMInterface):
|
||||
"prompt_cache_key": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
if use_forced_tool and schema is not None:
|
||||
# Single function tool whose parameters ARE the response schema;
|
||||
# force it via tool_choice so the backend does constrained decoding.
|
||||
payload["tools"] = [
|
||||
{
|
||||
"type": "function",
|
||||
"name": _STRUCTURED_TOOL_NAME,
|
||||
"description": "Return the structured response.",
|
||||
"parameters": schema,
|
||||
}
|
||||
]
|
||||
payload["tool_choice"] = {"type": "function", "name": _STRUCTURED_TOOL_NAME}
|
||||
payload["parallel_tool_calls"] = False
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"Content-Type": "application/json",
|
||||
@@ -412,8 +495,15 @@ class CodexLLM(LLMInterface):
|
||||
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse SSE stream
|
||||
content = await self._parse_sse_stream(response)
|
||||
# Forced-tool path: read structured output from the function-call
|
||||
# arguments (already a JSON string in a dedicated channel) rather
|
||||
# than from free-form assistant text.
|
||||
if use_forced_tool:
|
||||
text_content, tool_calls = await self._parse_sse_tool_stream(response)
|
||||
content = text_content or ""
|
||||
else:
|
||||
tool_calls = []
|
||||
content = await self._parse_sse_stream(response)
|
||||
|
||||
# Codex SSE carries no usage block; stash the same char/4 estimate
|
||||
# the success path traces so a later parse/validate failure records
|
||||
@@ -426,7 +516,28 @@ class CodexLLM(LLMInterface):
|
||||
)
|
||||
|
||||
# Handle structured output
|
||||
if response_format is not None:
|
||||
if use_forced_tool:
|
||||
tool_input = None
|
||||
for tc in tool_calls:
|
||||
if tc.name == _STRUCTURED_TOOL_NAME:
|
||||
tool_input = tc.arguments if isinstance(tc.arguments, dict) else None
|
||||
break
|
||||
if tool_input is None:
|
||||
# Model ignored the forced tool (rare — e.g. a gateway that
|
||||
# drops tool_choice). Retry so we don't hard-fail.
|
||||
logger.warning(
|
||||
f"Codex forced structured tool missing from response "
|
||||
f"(attempt {attempt + 1}/{max_retries + 1})"
|
||||
)
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
attempt += 1
|
||||
continue
|
||||
raise RuntimeError("Codex did not return the forced structured_response tool call")
|
||||
content = json.dumps(tool_input)
|
||||
result = tool_input if skip_validation else response_format.model_validate(tool_input)
|
||||
elif response_format is not None:
|
||||
# Models may wrap JSON in markdown
|
||||
clean_content = content
|
||||
if "```json" in content:
|
||||
@@ -437,13 +548,20 @@ class CodexLLM(LLMInterface):
|
||||
try:
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
attempt += 1
|
||||
continue
|
||||
raise
|
||||
# Escape-heavy content deterministically re-fails every
|
||||
# retry (issue #2504). Try a lenient invalid-escape repair
|
||||
# before burning a retry / re-raising.
|
||||
try:
|
||||
json_data = json.loads(_repair_invalid_json_escapes(clean_content))
|
||||
logger.info("Codex JSON parsed after repairing invalid escape sequences")
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
attempt += 1
|
||||
continue
|
||||
raise
|
||||
|
||||
if skip_validation:
|
||||
result = json_data
|
||||
@@ -872,8 +990,13 @@ class CodexLLM(LLMInterface):
|
||||
try:
|
||||
arguments = json.loads(arguments_str)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
|
||||
arguments = {}
|
||||
# Escape-heavy content can emit invalid \escape
|
||||
# sequences (issue #2504); repair before giving up.
|
||||
try:
|
||||
arguments = json.loads(_repair_invalid_json_escapes(arguments_str))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
|
||||
arguments = {}
|
||||
|
||||
tool_calls.append(
|
||||
LLMToolCall(
|
||||
|
||||
@@ -67,23 +67,68 @@ class ProviderResponseError(RuntimeError):
|
||||
self.retryable = retryable
|
||||
|
||||
|
||||
def _is_json(text: str) -> bool:
|
||||
"""True if ``text`` parses as a JSON value."""
|
||||
try:
|
||||
json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _outer_json_span(content: str) -> str | None:
|
||||
"""Return the outermost ``{...}`` / ``[...]`` span if it parses as JSON, else None.
|
||||
|
||||
Fallback for responses where fences are partial/absent or the model wrapped
|
||||
the JSON in surrounding prose. Only returned when it is valid JSON so callers
|
||||
never receive a worse candidate than the raw content.
|
||||
"""
|
||||
starts = [i for i in (content.find("{"), content.find("[")) if i >= 0]
|
||||
ends = [i for i in (content.rfind("}"), content.rfind("]")) if i >= 0]
|
||||
if not starts or not ends:
|
||||
return None
|
||||
start, end = min(starts), max(ends)
|
||||
if end <= start:
|
||||
return None
|
||||
candidate = content[start : end + 1].strip()
|
||||
return candidate if _is_json(candidate) else None
|
||||
|
||||
|
||||
def _strip_code_fences(content: str) -> str:
|
||||
"""Strip markdown code fences from LLM response if present.
|
||||
|
||||
Many LLM providers (MiniMax, some Ollama models, Claude via proxies)
|
||||
wrap JSON responses in ```json ... ``` fences even when json_object
|
||||
response format is requested. This strips the fences while preserving
|
||||
the JSON content inside. Returns the original content unchanged if
|
||||
no fences are detected.
|
||||
response format is requested. Fences are detected by line (a closing
|
||||
``` must sit alone on its line) so triple-backticks *inside* JSON string
|
||||
values do not truncate the payload. When the stripped candidate is not
|
||||
valid JSON (partial fence, prose-wrapped output, truncated response), fall
|
||||
back to the outermost parseable JSON span. Returns the original content
|
||||
unchanged if no better candidate is found.
|
||||
"""
|
||||
if "```" not in content:
|
||||
return content
|
||||
try:
|
||||
if "```json" in content:
|
||||
return content.split("```json")[1].split("```")[0].strip()
|
||||
return content.split("```")[1].split("```")[0].strip()
|
||||
except (IndexError, ValueError):
|
||||
return content
|
||||
candidate = content
|
||||
if "```" in content:
|
||||
lines = content.split("\n")
|
||||
# Find first line that starts a code fence (``` optionally followed by language)
|
||||
fence_start = next((i for i, line in enumerate(lines) if line.startswith("```")), None)
|
||||
if fence_start is not None:
|
||||
# Find matching closing fence (``` alone or with trailing whitespace)
|
||||
fence_end = next(
|
||||
(j for j in range(fence_start + 1, len(lines)) if lines[j].strip() == "```"),
|
||||
None,
|
||||
)
|
||||
if fence_end is not None:
|
||||
candidate = "\n".join(lines[fence_start + 1 : fence_end]).strip()
|
||||
|
||||
if _is_json(candidate):
|
||||
return candidate
|
||||
|
||||
# Fence stripping did not yield valid JSON — try to recover the outer JSON span.
|
||||
span = _outer_json_span(content)
|
||||
if span is not None:
|
||||
return span
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
# Reasoning/thinking tags emitted by extended-thinking models. Some providers
|
||||
@@ -644,6 +689,11 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
# use the widely-supported max_tokens
|
||||
return "max_tokens"
|
||||
|
||||
def _apply_provider_extra_body_defaults(self, extra_body: dict[str, Any]) -> None:
|
||||
"""Apply provider-specific extra_body defaults while preserving user overrides."""
|
||||
if self.provider == "minimax":
|
||||
extra_body.setdefault("thinking", {"type": "disabled"})
|
||||
|
||||
async def call(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
@@ -731,6 +781,7 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
|
||||
# Provider-specific parameters
|
||||
extra_body: dict[str, Any] = {**self._config_extra_body}
|
||||
self._apply_provider_extra_body_defaults(extra_body)
|
||||
if self.provider == "groq":
|
||||
call_params["seed"] = DEFAULT_LLM_SEED
|
||||
# Add service_tier if configured
|
||||
@@ -1149,6 +1200,7 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
|
||||
# Provider-specific parameters
|
||||
extra_body: dict[str, Any] = {**self._config_extra_body}
|
||||
self._apply_provider_extra_body_defaults(extra_body)
|
||||
if self.provider == "groq":
|
||||
call_params["seed"] = DEFAULT_LLM_SEED
|
||||
if extra_body:
|
||||
|
||||
@@ -64,8 +64,53 @@ async def delete_chunks_by_ids(conn, chunk_ids: list[str]) -> None:
|
||||
"""
|
||||
if not chunk_ids:
|
||||
return
|
||||
|
||||
# PostgreSQL's FK cascade deletes child memory_links in executor-chosen
|
||||
# order. Concurrent chunk deletes for the same bank can then lock overlapping
|
||||
# memory_links in opposite orders and deadlock. Delete links explicitly in a
|
||||
# total order before deleting chunks so every writer takes row locks the same
|
||||
# way; the FK cascade still handles anything inserted later in this txn.
|
||||
await conn.execute(
|
||||
f"DELETE FROM {fq_table('chunks')} WHERE chunk_id = ANY($1::text[])",
|
||||
f"""
|
||||
WITH target_units AS MATERIALIZED (
|
||||
SELECT id
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE chunk_id = ANY($1::text[])
|
||||
),
|
||||
ordered_links AS MATERIALIZED (
|
||||
SELECT ml.ctid
|
||||
FROM {fq_table("memory_links")} ml
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM target_units tu
|
||||
WHERE tu.id = ml.from_unit_id OR tu.id = ml.to_unit_id
|
||||
)
|
||||
ORDER BY
|
||||
LEAST(ml.from_unit_id, ml.to_unit_id),
|
||||
GREATEST(ml.from_unit_id, ml.to_unit_id),
|
||||
ml.link_type,
|
||||
COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
FOR UPDATE OF ml
|
||||
)
|
||||
DELETE FROM {fq_table("memory_links")} ml
|
||||
USING ordered_links ol
|
||||
WHERE ml.ctid = ol.ctid
|
||||
""",
|
||||
chunk_ids,
|
||||
)
|
||||
await conn.execute(
|
||||
f"""
|
||||
WITH ordered_chunks AS MATERIALIZED (
|
||||
SELECT chunk_id
|
||||
FROM {fq_table("chunks")}
|
||||
WHERE chunk_id = ANY($1::text[])
|
||||
ORDER BY chunk_id
|
||||
FOR UPDATE
|
||||
)
|
||||
DELETE FROM {fq_table("chunks")} c
|
||||
USING ordered_chunks oc
|
||||
WHERE c.chunk_id = oc.chunk_id
|
||||
""",
|
||||
chunk_ids,
|
||||
)
|
||||
|
||||
|
||||
@@ -232,6 +232,55 @@ class FactExtractionResponse(BaseModel):
|
||||
facts: list[ExtractedFact] = Field(description="List of extracted factual statements")
|
||||
|
||||
|
||||
def _split_chunk_for_output_retry(chunk: str) -> tuple[str, str] | None:
|
||||
"""Split an oversized extraction chunk without corrupting structured input."""
|
||||
stripped = chunk.strip()
|
||||
if len(stripped) <= 1:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
parsed = None
|
||||
|
||||
if isinstance(parsed, list):
|
||||
if len(parsed) >= 2:
|
||||
mid = len(parsed) // 2
|
||||
return json.dumps(parsed[:mid]), json.dumps(parsed[mid:])
|
||||
|
||||
if len(parsed) == 1 and isinstance(parsed[0], dict):
|
||||
turn = parsed[0]
|
||||
content = turn.get("content")
|
||||
if isinstance(content, str) and len(content) > 1:
|
||||
cut = len(content) // 2
|
||||
first_turn = dict(turn)
|
||||
second_turn = dict(turn)
|
||||
first_turn["content"] = content[:cut]
|
||||
second_turn["content"] = content[cut:]
|
||||
return json.dumps([first_turn]), json.dumps([second_turn])
|
||||
|
||||
return None
|
||||
|
||||
# Split plain text at the midpoint, preferring sentence boundaries nearby.
|
||||
mid_point = len(stripped) // 2
|
||||
search_range = int(len(stripped) * 0.2)
|
||||
search_start = max(0, mid_point - search_range)
|
||||
search_end = min(len(stripped), mid_point + search_range)
|
||||
|
||||
best_split = mid_point
|
||||
for ending in [". ", "! ", "? ", "\n\n"]:
|
||||
pos = stripped.rfind(ending, search_start, search_end)
|
||||
if pos != -1:
|
||||
best_split = pos + len(ending)
|
||||
break
|
||||
|
||||
first_half = stripped[:best_split].strip()
|
||||
second_half = stripped[best_split:].strip()
|
||||
if not first_half or not second_half or first_half == stripped or second_half == stripped:
|
||||
return None
|
||||
return first_half, second_half
|
||||
|
||||
|
||||
class ExtractedFactVerbose(BaseModel):
|
||||
"""A single extracted fact with verbose field descriptions for detailed extraction."""
|
||||
|
||||
@@ -1664,33 +1713,22 @@ async def _extract_facts_with_auto_split(
|
||||
metadata=metadata,
|
||||
)
|
||||
except OutputTooLongError:
|
||||
# Output exceeded token limits - split the chunk in half and retry
|
||||
# Output exceeded token limits - split the chunk and retry. Conversation
|
||||
# chunks are JSON arrays, so preserve array/turn boundaries when possible.
|
||||
logger.warning(
|
||||
f"Output too long for chunk {chunk_index + 1}/{total_chunks} "
|
||||
f"({len(chunk)} chars). Splitting in half and retrying..."
|
||||
f"({len(chunk)} chars). Splitting and retrying..."
|
||||
)
|
||||
|
||||
# Split at the midpoint, preferring sentence boundaries
|
||||
mid_point = len(chunk) // 2
|
||||
split_chunks = _split_chunk_for_output_retry(chunk)
|
||||
if split_chunks is None:
|
||||
logger.warning(
|
||||
f"Cannot make progress splitting chunk {chunk_index + 1}/{total_chunks} "
|
||||
f"({len(chunk)} chars); dropping this sub-chunk."
|
||||
)
|
||||
return [], TokenUsage()
|
||||
|
||||
# Try to find a sentence boundary near the midpoint
|
||||
# Look for ". ", "! ", "? " within 20% of midpoint
|
||||
search_range = int(len(chunk) * 0.2)
|
||||
search_start = max(0, mid_point - search_range)
|
||||
search_end = min(len(chunk), mid_point + search_range)
|
||||
|
||||
sentence_endings = [". ", "! ", "? ", "\n\n"]
|
||||
best_split = mid_point
|
||||
|
||||
for ending in sentence_endings:
|
||||
pos = chunk.rfind(ending, search_start, search_end)
|
||||
if pos != -1:
|
||||
best_split = pos + len(ending)
|
||||
break
|
||||
|
||||
# Split the chunk
|
||||
first_half = chunk[:best_split].strip()
|
||||
second_half = chunk[best_split:].strip()
|
||||
first_half, second_half = split_chunks
|
||||
|
||||
logger.info(
|
||||
f"Split chunk {chunk_index + 1} into two sub-chunks: {len(first_half)} chars and {len(second_half)} chars"
|
||||
|
||||
@@ -15,7 +15,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from ...config import get_config
|
||||
from ...config import DEFAULT_BM25_MAX_QUERY_TERMS, get_config
|
||||
from ..db_utils import acquire_with_retry
|
||||
from ..memory_engine import fq_table
|
||||
from ..sql import create_sql_dialect
|
||||
@@ -222,7 +222,12 @@ async def retrieve_semantic_bm25_combined(
|
||||
# --- BM25 UNION ALL arms (one per fact_type, only when tokens present) ---
|
||||
if _include_bm25:
|
||||
text_ext = config.text_search_extension
|
||||
bm25_text_param: str = dialect.prepare_bm25_text(tokens, query_text, text_search_extension=text_ext)
|
||||
bm25_text_param: str = dialect.prepare_bm25_text(
|
||||
tokens,
|
||||
query_text,
|
||||
text_search_extension=text_ext,
|
||||
max_query_terms=getattr(config, "bm25_max_query_terms", DEFAULT_BM25_MAX_QUERY_TERMS),
|
||||
)
|
||||
for i, ft in enumerate(fact_types):
|
||||
arms.append(
|
||||
dialect.build_bm25_arm(
|
||||
|
||||
@@ -449,6 +449,7 @@ class SQLDialect(ABC):
|
||||
query_text: str,
|
||||
*,
|
||||
text_search_extension: str = "native",
|
||||
max_query_terms: int | None = None,
|
||||
) -> str:
|
||||
"""Prepare the text parameter value for BM25 search.
|
||||
|
||||
@@ -459,6 +460,8 @@ class SQLDialect(ABC):
|
||||
tokens: Tokenized query words.
|
||||
query_text: Original query text.
|
||||
text_search_extension: Full-text search backend variant.
|
||||
max_query_terms: Optional backend-specific token cap. 0 or None
|
||||
leaves query terms uncapped.
|
||||
|
||||
Returns:
|
||||
Prepared text string to bind as the BM25 text parameter.
|
||||
|
||||
@@ -303,6 +303,7 @@ class OracleDialect(SQLDialect):
|
||||
query_text: str,
|
||||
*,
|
||||
text_search_extension: str = "native",
|
||||
max_query_terms: int | None = None,
|
||||
) -> str:
|
||||
# Oracle Text: filter tokens with special chars, escape reserved words
|
||||
# with curly braces (e.g. "about" → "{about}"), and join with OR.
|
||||
|
||||
@@ -254,8 +254,11 @@ class PostgreSQLDialect(SQLDialect):
|
||||
query_text: str,
|
||||
*,
|
||||
text_search_extension: str = "native",
|
||||
max_query_terms: int | None = None,
|
||||
) -> str:
|
||||
if text_search_extension in ("vchord", "pg_textsearch", "pgroonga", "pg_search"):
|
||||
return query_text
|
||||
if max_query_terms is not None and max_query_terms > 0:
|
||||
tokens = tokens[:max_query_terms]
|
||||
# native tsvector: join tokens with OR operator
|
||||
return " | ".join(tokens)
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest_asyncio
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.api.http import BankTemplateManifest, validate_bank_template
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -81,6 +82,17 @@ class TestImportValidation:
|
||||
assert set(data["mental_models_created"]) == {"test-model-one", "test-model-two"}
|
||||
assert set(data["directives_created"]) == {"Be concise", "Use examples"}
|
||||
|
||||
def test_verbatim_extraction_mode_is_valid(self):
|
||||
"""verbatim is a valid retain extraction mode in bank manifests."""
|
||||
manifest = BankTemplateManifest.model_validate(
|
||||
{
|
||||
"version": "1",
|
||||
"bank": {"retain_extraction_mode": "verbatim"},
|
||||
}
|
||||
)
|
||||
|
||||
assert validate_bank_template(manifest) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_invalid_version(self, api_client, bank_id):
|
||||
"""Reject manifest with unsupported version."""
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Regression coverage for deterministic chunk deletion ordering."""
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.retain import chunk_storage
|
||||
|
||||
|
||||
class RecordingConn:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, tuple[object, ...]]] = []
|
||||
|
||||
async def execute(self, sql: str, *args: object) -> None:
|
||||
self.calls.append((sql, args))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_chunks_by_ids_predeletes_links_before_chunks():
|
||||
conn = RecordingConn()
|
||||
chunk_ids = ["chunk-b", "chunk-a"]
|
||||
|
||||
await chunk_storage.delete_chunks_by_ids(conn, chunk_ids)
|
||||
|
||||
assert len(conn.calls) == 2
|
||||
link_sql, link_args = conn.calls[0]
|
||||
chunk_sql, chunk_args = conn.calls[1]
|
||||
|
||||
assert link_args == (chunk_ids,)
|
||||
assert chunk_args == (chunk_ids,)
|
||||
|
||||
assert "DELETE FROM" in link_sql
|
||||
assert "memory_links" in link_sql
|
||||
assert "target_units AS MATERIALIZED" in link_sql
|
||||
assert "ordered_links AS MATERIALIZED" in link_sql
|
||||
assert "ORDER BY" in link_sql
|
||||
assert "FOR UPDATE OF ml" in link_sql
|
||||
|
||||
assert "DELETE FROM" in chunk_sql
|
||||
assert "chunks" in chunk_sql
|
||||
assert "ordered_chunks AS MATERIALIZED" in chunk_sql
|
||||
assert "ORDER BY chunk_id" in chunk_sql
|
||||
assert "FOR UPDATE" in chunk_sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_chunks_by_ids_noops_without_chunks():
|
||||
conn = RecordingConn()
|
||||
|
||||
await chunk_storage.delete_chunks_by_ids(conn, [])
|
||||
|
||||
assert conn.calls == []
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Regression tests for Codex structured output (issue #2504).
|
||||
|
||||
Before the fix, ``CodexLLM.call(strict_schema=True)`` was a dead no-op: structured
|
||||
output always went through prompt-injected schema + raw ``json.loads`` on the
|
||||
model's free-form text. Escape-heavy content (code, serial/CLI commands, Windows
|
||||
paths, regexes) makes weaker models emit invalid ``\\escape`` sequences, so every
|
||||
parse attempt fails and retain/consolidation burn all retries and fail.
|
||||
|
||||
The fix:
|
||||
- ``strict_schema=True`` routes structured output through a single forced function
|
||||
tool (constrained decoding into the response schema).
|
||||
- The non-strict fallback now repairs invalid ``\\escape`` sequences before giving up.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from hindsight_api.engine.providers.codex_llm import (
|
||||
CodexLLM,
|
||||
_repair_invalid_json_escapes,
|
||||
)
|
||||
from hindsight_api.engine.response_models import LLMToolCall
|
||||
|
||||
|
||||
class _Fact(BaseModel):
|
||||
fact: str
|
||||
|
||||
|
||||
def build_llm() -> CodexLLM:
|
||||
with patch.object(CodexLLM, "_load_codex_auth", return_value=("token", "account")):
|
||||
return CodexLLM(
|
||||
provider="openai-codex",
|
||||
api_key="ignored",
|
||||
base_url="https://chatgpt.com/backend-api",
|
||||
model="gpt-5.4-mini",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _repair_invalid_json_escapes — pure unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_repair_fixes_invalid_escape_in_json():
|
||||
# `\d` and `\s` are not valid JSON escapes; raw json.loads fails.
|
||||
broken = r'{"fact": "regex \d+\s matches digits"}'
|
||||
import json
|
||||
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
json.loads(broken)
|
||||
repaired = _repair_invalid_json_escapes(broken)
|
||||
assert json.loads(repaired) == {"fact": r"regex \d+\s matches digits"}
|
||||
|
||||
|
||||
def test_repair_preserves_valid_escapes():
|
||||
import json
|
||||
|
||||
valid = r'{"fact": "line1\nline2\ttab \"quoted\" \\backslash é"}'
|
||||
# Already valid — repair must not corrupt it.
|
||||
assert json.loads(_repair_invalid_json_escapes(valid)) == json.loads(valid)
|
||||
|
||||
|
||||
def test_repair_handles_windows_paths():
|
||||
import json
|
||||
|
||||
# Uses path segments whose first char isn't a valid JSON escape letter
|
||||
# (b/f/n/r/t/u), where the repair is unambiguous.
|
||||
broken = r'{"path": "C:\Windows\System32\app.exe"}'
|
||||
assert json.loads(_repair_invalid_json_escapes(broken)) == {"path": r"C:\Windows\System32\app.exe"}
|
||||
|
||||
|
||||
def test_repair_handles_trailing_backslash():
|
||||
# A lone trailing backslash must be escaped, not dropped.
|
||||
assert _repair_invalid_json_escapes("abc\\") == "abc\\\\"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# strict_schema forced-tool path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_schema_uses_forced_function_tool():
|
||||
llm = build_llm()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
tool_call = LLMToolCall(id="call-1", name="structured_response", arguments={"fact": "the sky is blue"})
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = response
|
||||
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
|
||||
mock_parse.return_value = (None, [tool_call])
|
||||
result = await llm.call(
|
||||
messages=[{"role": "user", "content": "The sky is blue"}],
|
||||
response_format=_Fact,
|
||||
strict_schema=True,
|
||||
max_retries=0,
|
||||
)
|
||||
sent_payload = mock_post.call_args.kwargs["json"]
|
||||
|
||||
# Forced tool wired into the request payload.
|
||||
assert sent_payload["tool_choice"] == {"type": "function", "name": "structured_response"}
|
||||
assert len(sent_payload["tools"]) == 1
|
||||
assert sent_payload["tools"][0]["name"] == "structured_response"
|
||||
assert sent_payload["parallel_tool_calls"] is False
|
||||
# No prompt-injected schema in the instructions.
|
||||
assert "You must respond with valid JSON" not in sent_payload["instructions"]
|
||||
|
||||
assert isinstance(result, _Fact)
|
||||
assert result.fact == "the sky is blue"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_schema_skip_validation_returns_dict():
|
||||
llm = build_llm()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
tool_call = LLMToolCall(id="c", name="structured_response", arguments={"fact": "x"})
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = response
|
||||
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
|
||||
mock_parse.return_value = (None, [tool_call])
|
||||
result = await llm.call(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
response_format=_Fact,
|
||||
strict_schema=True,
|
||||
skip_validation=True,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
assert result == {"fact": "x"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_schema_retries_when_forced_tool_missing():
|
||||
llm = build_llm()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = response
|
||||
# Model returns no tool call at all — should raise after retries exhausted.
|
||||
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
|
||||
mock_parse.return_value = ("some prose", [])
|
||||
with pytest.raises(RuntimeError, match="structured_response"):
|
||||
await llm.call(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
response_format=_Fact,
|
||||
strict_schema=True,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-strict fallback: escape repair keeps the retry storm from happening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_strict_repairs_invalid_escapes_without_retrying():
|
||||
llm = build_llm()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
# Escape-heavy content the model would emit as invalid JSON.
|
||||
escape_heavy = r'{"fact": "run rig-control \d serial \s command"}'
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = response
|
||||
with patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock) as mock_parse:
|
||||
mock_parse.return_value = escape_heavy
|
||||
result = await llm.call(
|
||||
messages=[{"role": "user", "content": "coding transcript"}],
|
||||
response_format=_Fact,
|
||||
strict_schema=False,
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
# Parsed on the first attempt (no retry storm): the SSE stream was read once.
|
||||
assert mock_post.await_count == 1
|
||||
assert isinstance(result, _Fact)
|
||||
assert result.fact == r"run rig-control \d serial \s command"
|
||||
@@ -5,6 +5,7 @@ guard the fix in CI — unlike the real-LLM integration test, which only trigger
|
||||
the path stochastically.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import types
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
@@ -142,6 +143,38 @@ async def test_dedup_llm_missing_action_defaults_to_keep() -> None:
|
||||
conn.execute.assert_not_called() # missing action is a conservative no-merge
|
||||
|
||||
|
||||
def test_dedup_decision_accepts_exact_valid_actions() -> None:
|
||||
assert _DedupDecision(action="merge").action == "merge"
|
||||
assert _DedupDecision(action="keep").action == "keep"
|
||||
|
||||
|
||||
def test_dedup_decision_invalid_action_defaults_to_keep(caplog) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
decision = _DedupDecision(action="need_input", reason="model asked for more context")
|
||||
|
||||
assert decision.action == "keep"
|
||||
assert "need_input" in caplog.text
|
||||
assert "defaulting to keep" in caplog.text
|
||||
|
||||
|
||||
def test_dedup_decision_near_miss_merge_defaults_to_keep(caplog) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
decision = _DedupDecision(action="Merge")
|
||||
|
||||
assert decision.action == "keep"
|
||||
assert "Merge" in caplog.text
|
||||
|
||||
|
||||
def test_dedup_decision_non_scalar_action_defaults_to_keep(caplog) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
list_decision = _DedupDecision(action=[])
|
||||
dict_decision = _DedupDecision(action={"value": "merge"})
|
||||
|
||||
assert list_decision.action == "keep"
|
||||
assert dict_decision.action == "keep"
|
||||
assert "defaulting to keep" in caplog.text
|
||||
|
||||
|
||||
async def test_dedup_llm_merge_folds_into_twin() -> None:
|
||||
kwargs, conn, llm = _ctx()
|
||||
kwargs["create_source_ids"] = [uuid.uuid4(), uuid.uuid4()]
|
||||
|
||||
@@ -78,6 +78,39 @@ async def test_patch_invalidate_and_revert_over_http(api_client, memory):
|
||||
await memory.delete_bank(bank_id, request_context=RequestContext())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_clears_occurred_dates_with_explicit_null(api_client, memory):
|
||||
bank_id = f"curation-http-clear-dates-{uuid.uuid4().hex[:8]}"
|
||||
mem_id = await _insert_fact(memory, bank_id, "Release v1.2 happened on Monday.")
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE memory_units
|
||||
SET occurred_start = '2024-01-15T10:30:00Z',
|
||||
occurred_end = '2024-01-15T11:00:00Z'
|
||||
WHERE id = $1
|
||||
""",
|
||||
uuid.UUID(mem_id),
|
||||
)
|
||||
|
||||
resp = await api_client.patch(
|
||||
f"/v1/default/banks/{bank_id}/memories/{mem_id}",
|
||||
json={"occurred_start": None, "occurred_end": None},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["occurred_start"] is None
|
||||
assert resp.json()["occurred_end"] is None
|
||||
|
||||
resp = await api_client.get(f"/v1/default/banks/{bank_id}/memories/{mem_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["occurred_start"] is None
|
||||
assert resp.json()["occurred_end"] is None
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=RequestContext())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_not_found_returns_404(api_client, memory):
|
||||
bank_id = f"curation-http-404-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@@ -4,8 +4,7 @@ Unit tests that verify the abstraction interfaces work correctly
|
||||
without requiring a live database connection.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -748,6 +747,85 @@ class TestOracleOpsInsertFactsBatch:
|
||||
assert rows_data[0][13] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL search_vector handling (insert). Since the curation archive drops
|
||||
# search_vector (#2503), the insert is the single place it is populated, and
|
||||
# pg_search_vector_expr is its one source of truth (shared with revert recompute).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPostgreSQLSearchVector:
|
||||
@staticmethod
|
||||
def _cfg(ext: str, lang: str = "english"):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(text_search_extension=ext, text_search_extension_native_language=lang)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ext,needle",
|
||||
[
|
||||
("native", "to_tsvector('english'::regconfig,"),
|
||||
("vchord", "::bm25_catalog.bm25vector"),
|
||||
],
|
||||
)
|
||||
def test_expr_builds_vector_for_vector_backends(self, ext, needle):
|
||||
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
|
||||
|
||||
expr = pg_search_vector_expr(self._cfg(ext))
|
||||
assert expr is not None and needle in expr
|
||||
# Always built from the same three carried columns.
|
||||
assert "COALESCE(text, '')" in expr and "COALESCE(text_signals, '')" in expr
|
||||
|
||||
@pytest.mark.parametrize("ext", ["pgroonga", "pg_textsearch", "pg_search"])
|
||||
def test_expr_none_for_base_column_backends(self, ext):
|
||||
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
|
||||
|
||||
# These index the base text columns directly; search_vector stays empty.
|
||||
assert pg_search_vector_expr(self._cfg(ext)) is None
|
||||
|
||||
def test_expr_accepts_custom_column_refs(self):
|
||||
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
|
||||
|
||||
expr = pg_search_vector_expr(self._cfg("native"), text_col="mu.text", context_col="mu.context")
|
||||
assert "COALESCE(mu.text, '')" in expr and "COALESCE(mu.context, '')" in expr
|
||||
|
||||
async def _insert_query(self, ext: str) -> str:
|
||||
from hindsight_api.engine.db.ops_postgresql import PostgreSQLOps
|
||||
|
||||
conn = AsyncMock(spec=DatabaseConnection)
|
||||
conn.fetch = AsyncMock(return_value=[{"id": "00000000-0000-0000-0000-000000000001"}])
|
||||
batch = dict(
|
||||
bank_id="b",
|
||||
fact_texts=["t"],
|
||||
embeddings=["[0.1]"],
|
||||
event_dates=[None],
|
||||
occurred_starts=[None],
|
||||
occurred_ends=[None],
|
||||
mentioned_ats=[None],
|
||||
contexts=["c"],
|
||||
fact_types=["world"],
|
||||
metadata_jsons=["{}"],
|
||||
chunk_ids=[None],
|
||||
document_ids=[None],
|
||||
tags_list=[""],
|
||||
observation_scopes_list=[None],
|
||||
text_signals_list=[None],
|
||||
)
|
||||
with patch("hindsight_api.config.get_config", return_value=self._cfg(ext)):
|
||||
await PostgreSQLOps().insert_facts_batch(conn=conn, **batch)
|
||||
return conn.fetch.call_args.args[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("ext", ["native", "vchord"])
|
||||
async def test_insert_includes_search_vector_column(self, ext):
|
||||
assert "search_vector" in await self._insert_query(ext)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("ext", ["pgroonga", "pg_textsearch", "pg_search"])
|
||||
async def test_insert_omits_search_vector_column(self, ext):
|
||||
assert "search_vector" not in await self._insert_query(ext)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_schema tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -9,12 +9,87 @@ BaseException'), which happened when last_error was only set in the
|
||||
BadRequestError handler and not for non-dict JSON responses.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_output_retry_split_preserves_conversation_array_boundaries():
|
||||
"""OutputTooLong retry splitting must keep conversation chunks valid JSON arrays."""
|
||||
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
|
||||
|
||||
turns = [
|
||||
{"role": "user", "content": "alpha"},
|
||||
{"role": "assistant", "content": "bravo"},
|
||||
{"role": "user", "content": "charlie"},
|
||||
{"role": "assistant", "content": "delta"},
|
||||
]
|
||||
|
||||
split = _split_chunk_for_output_retry(json.dumps(turns))
|
||||
|
||||
assert split is not None
|
||||
first, second = split
|
||||
assert json.loads(first) == turns[:2]
|
||||
assert json.loads(second) == turns[2:]
|
||||
|
||||
|
||||
def test_output_retry_split_divides_single_oversized_turn_content():
|
||||
"""A lone oversized conversation turn is split inside content and rewrapped."""
|
||||
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
|
||||
|
||||
turn = {"role": "user", "content": "abcdefghijklmnopqrstuvwxyz", "name": "casey"}
|
||||
|
||||
split = _split_chunk_for_output_retry(json.dumps([turn]))
|
||||
|
||||
assert split is not None
|
||||
first, second = split
|
||||
first_turn = json.loads(first)[0]
|
||||
second_turn = json.loads(second)[0]
|
||||
assert first_turn["role"] == "user"
|
||||
assert second_turn["role"] == "user"
|
||||
assert first_turn["name"] == "casey"
|
||||
assert second_turn["name"] == "casey"
|
||||
assert first_turn["content"] + second_turn["content"] == turn["content"]
|
||||
|
||||
|
||||
def test_output_retry_split_returns_none_when_no_progress_possible():
|
||||
"""Pathological tiny chunks should be dropped instead of recursively retried."""
|
||||
from hindsight_api.engine.retain.fact_extraction import _split_chunk_for_output_retry
|
||||
|
||||
assert _split_chunk_for_output_retry("x") is None
|
||||
assert _split_chunk_for_output_retry(json.dumps([{"role": "user", "content": ""}])) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_too_long_drops_unsplittable_subchunk_without_recursing():
|
||||
"""If a chunk cannot be reduced further, auto-split exits gracefully."""
|
||||
from hindsight_api.engine.llm_wrapper import OutputTooLongError
|
||||
from hindsight_api.engine.retain.fact_extraction import _extract_facts_with_auto_split
|
||||
|
||||
config = _make_config(llm_max_retries=1)
|
||||
llm_config = _make_llm_config(mock_response={})
|
||||
|
||||
with patch(
|
||||
"hindsight_api.engine.retain.fact_extraction._extract_facts_from_chunk",
|
||||
side_effect=OutputTooLongError("too long"),
|
||||
) as extract:
|
||||
facts, usage = await _extract_facts_with_auto_split(
|
||||
chunk="x",
|
||||
chunk_index=0,
|
||||
total_chunks=1,
|
||||
event_date=datetime(2023, 1, 1, tzinfo=timezone.utc),
|
||||
context="",
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name="agent",
|
||||
)
|
||||
|
||||
assert facts == []
|
||||
assert extract.call_count == 1
|
||||
|
||||
|
||||
def _make_config(llm_max_retries: int = 3, retain_llm_max_retries: int | None = None):
|
||||
"""Build a minimal HindsightConfig for fact extraction tests."""
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
"""HTTP + engine integration tests for the knowledge base (folders + pages).
|
||||
|
||||
Pages are seeded directly via the engine (deterministic content, no LLM) so the
|
||||
tree, OKF projection, move/rename, and cascade-delete behaviour can be asserted
|
||||
without consolidation.
|
||||
"""
|
||||
|
||||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
import pytest_asyncio
|
||||
|
||||
from hindsight_api.engine.memory_engine import MemoryEngine
|
||||
|
||||
|
||||
def _enc(bank_id: str) -> str:
|
||||
return urllib.parse.quote(bank_id, safe="")
|
||||
|
||||
|
||||
class _Seed:
|
||||
"""Holds the ids created by the seed fixture for assertions."""
|
||||
|
||||
def __init__(self, **ids):
|
||||
self.__dict__.update(ids)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def kb_bank(memory: MemoryEngine, request_context):
|
||||
"""A bank with folders, nested folders, and pages."""
|
||||
bank_id = f"test-kb-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
runbooks = await memory.create_knowledge_folder(bank_id, "Runbooks", request_context=request_context)
|
||||
policies = await memory.create_knowledge_folder(bank_id, "Policies", request_context=request_context)
|
||||
sub = await memory.create_knowledge_folder(
|
||||
bank_id, "Sub", parent_id=runbooks["id"], request_context=request_context
|
||||
)
|
||||
orders = await memory.create_knowledge_page(
|
||||
bank_id,
|
||||
"Orders",
|
||||
"What are the order facts?",
|
||||
"# Orders\n\nOne row per order.",
|
||||
parent_id=runbooks["id"],
|
||||
tags=["type:runbook", "sales", "revenue"],
|
||||
request_context=request_context,
|
||||
)
|
||||
billing = await memory.create_knowledge_page(
|
||||
bank_id,
|
||||
"Billing",
|
||||
"What is the billing policy?",
|
||||
"# Billing\n\nNet-30.",
|
||||
parent_id=policies["id"],
|
||||
tags=["type:policy", "revenue"],
|
||||
request_context=request_context,
|
||||
)
|
||||
loose = await memory.create_knowledge_page(
|
||||
bank_id,
|
||||
"Loose",
|
||||
"A root page.",
|
||||
"# Loose\n\nNo folder, no tags.",
|
||||
tags=[],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
yield (
|
||||
bank_id,
|
||||
_Seed(
|
||||
runbooks=runbooks["id"],
|
||||
policies=policies["id"],
|
||||
sub=sub["id"],
|
||||
orders=orders["id"],
|
||||
billing=billing["id"],
|
||||
loose=loose["id"],
|
||||
orders_mm=orders["mental_model_id"],
|
||||
),
|
||||
)
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestTree:
|
||||
async def test_nested_tree(self, api_client, kb_bank):
|
||||
bank_id, ids = kb_bank
|
||||
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/tree")
|
||||
assert resp.status_code == 200, resp.text
|
||||
roots = {r["name"]: r for r in resp.json()["roots"]}
|
||||
assert set(roots) == {"Runbooks", "Policies", "Loose"}
|
||||
|
||||
runbooks = roots["Runbooks"]
|
||||
assert runbooks["kind"] == "folder"
|
||||
child_names = {c["name"] for c in runbooks["children"]}
|
||||
assert child_names == {"Sub", "Orders"}
|
||||
|
||||
orders = next(c for c in runbooks["children"] if c["name"] == "Orders")
|
||||
assert orders["kind"] == "page"
|
||||
# Human-created pages are pinned (not curator-managed).
|
||||
assert orders["managed"] is False
|
||||
assert "sales" in orders["tags"]
|
||||
assert roots["Loose"]["kind"] == "page"
|
||||
|
||||
|
||||
class TestPageDefaults:
|
||||
"""A knowledge page is a living document by default: observation-only, delta,
|
||||
auto-refreshing, with a larger token budget than a plain mental model."""
|
||||
|
||||
async def test_default_trigger_and_max_tokens(self, memory: MemoryEngine, request_context):
|
||||
bank_id = f"test-kb-def-{uuid.uuid4().hex[:8]}"
|
||||
page = await memory.create_knowledge_page(
|
||||
bank_id, "P", "What is P?", "seed", request_context=request_context
|
||||
)
|
||||
mm = await memory.get_mental_model(bank_id, page["mental_model_id"], request_context=request_context)
|
||||
assert mm["trigger"] == {
|
||||
"mode": "delta",
|
||||
"fact_types": ["observation"],
|
||||
"exclude_mental_models": True,
|
||||
"refresh_after_consolidation": True,
|
||||
}
|
||||
assert mm["max_tokens"] == 4096
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
async def test_client_trigger_and_max_tokens_override_defaults(self, memory: MemoryEngine, request_context):
|
||||
bank_id = f"test-kb-ovr-{uuid.uuid4().hex[:8]}"
|
||||
page = await memory.create_knowledge_page(
|
||||
bank_id,
|
||||
"P",
|
||||
"What is P?",
|
||||
"seed",
|
||||
trigger={"mode": "full", "refresh_after_consolidation": False},
|
||||
max_tokens=1024,
|
||||
request_context=request_context,
|
||||
)
|
||||
mm = await memory.get_mental_model(bank_id, page["mental_model_id"], request_context=request_context)
|
||||
assert mm["trigger"]["mode"] == "full"
|
||||
assert mm["trigger"].get("refresh_after_consolidation") is False
|
||||
assert mm["max_tokens"] == 1024
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestGetPage:
|
||||
async def test_okf_document(self, api_client, kb_bank):
|
||||
bank_id, ids = kb_bank
|
||||
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/pages/{ids.orders}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
page = resp.json()
|
||||
assert page["type"] == "runbook"
|
||||
assert page["body"].startswith("# Orders")
|
||||
assert page["markdown"].startswith("---\n")
|
||||
assert 'type: "runbook"' in page["markdown"]
|
||||
|
||||
async def test_missing_page_404(self, api_client, kb_bank):
|
||||
bank_id, ids = kb_bank
|
||||
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/pages/nope")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestCreate:
|
||||
async def test_create_folder(self, api_client, kb_bank):
|
||||
bank_id, ids = kb_bank
|
||||
resp = await api_client.post(
|
||||
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/folders",
|
||||
json={"name": "Guides", "parent_id": None},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["kind"] == "folder"
|
||||
assert resp.json()["name"] == "Guides"
|
||||
|
||||
async def test_create_folder_bad_parent(self, api_client, kb_bank):
|
||||
bank_id, ids = kb_bank
|
||||
# parent that is a page, not a folder → 400
|
||||
resp = await api_client.post(
|
||||
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/folders",
|
||||
json={"name": "Nope", "parent_id": ids.orders},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestGraphAndExport:
|
||||
async def test_graph_shared_tag_edge(self, api_client, kb_bank):
|
||||
bank_id, ids = kb_bank
|
||||
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/graph")
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["total_pages"] == 3
|
||||
# orders & billing share "revenue"; loose has no tags
|
||||
assert data["total_edges"] == 1
|
||||
edge = data["edges"][0]["data"]
|
||||
assert {edge["source"], edge["target"]} == {ids.orders, ids.billing}
|
||||
assert edge["sharedTags"] == ["revenue"]
|
||||
|
||||
async def test_export_bundle_nested_index(self, api_client, kb_bank):
|
||||
bank_id, ids = kb_bank
|
||||
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/export")
|
||||
assert resp.status_code == 200, resp.text
|
||||
files = {f["path"]: f["content"] for f in resp.json()["files"]}
|
||||
assert "index.md" in files
|
||||
assert f"{ids.orders}.md" in files
|
||||
# index reflects the folder hierarchy
|
||||
assert "**Runbooks/**" in files["index.md"]
|
||||
assert "One row per order." in files[f"{ids.orders}.md"]
|
||||
|
||||
|
||||
class TestMoveRenameDelete:
|
||||
async def test_rename(self, api_client, kb_bank):
|
||||
bank_id, ids = kb_bank
|
||||
resp = await api_client.patch(
|
||||
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.policies}",
|
||||
json={"name": "Compliance"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["name"] == "Compliance"
|
||||
|
||||
async def test_move_into_folder(self, api_client, kb_bank):
|
||||
bank_id, ids = kb_bank
|
||||
# move the Loose root page under Policies
|
||||
resp = await api_client.patch(
|
||||
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.loose}",
|
||||
json={"parent_id": ids.policies},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["parent_id"] == ids.policies
|
||||
|
||||
async def test_move_cycle_rejected(self, api_client, kb_bank):
|
||||
bank_id, ids = kb_bank
|
||||
# moving Runbooks under its own descendant Sub must fail
|
||||
resp = await api_client.patch(
|
||||
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.runbooks}",
|
||||
json={"parent_id": ids.sub},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_delete_folder_cascades(self, api_client, kb_bank, memory, request_context):
|
||||
bank_id, ids = kb_bank
|
||||
# deleting Runbooks removes Sub + Orders (and Orders' mental model)
|
||||
resp = await api_client.delete(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.runbooks}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
tree = (await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/tree")).json()
|
||||
root_names = {r["name"] for r in tree["roots"]}
|
||||
assert "Runbooks" not in root_names
|
||||
# the backing mental model is gone too
|
||||
mm = await memory.get_mental_model(bank_id, ids.orders_mm, request_context=request_context)
|
||||
assert mm is None
|
||||
@@ -182,7 +182,7 @@ def test_batch_request_body_strict_follows_config(strict):
|
||||
from hindsight_api.engine.retain.fact_extraction import _build_request_body
|
||||
|
||||
llm_config = SimpleNamespace(model="gpt-4o-mini", provider="openai", _provider_impl=SimpleNamespace())
|
||||
config = SimpleNamespace(retain_max_completion_tokens=None, llm_strict_schema=strict)
|
||||
config = SimpleNamespace(retain_max_completion_tokens=None, llm_strict_schema=strict, llm_temperature_retain=None)
|
||||
# provider != "openai" service-tier branch skipped via _provider_impl without attr
|
||||
llm_config._provider_impl.openai_service_tier = None
|
||||
|
||||
|
||||
@@ -63,3 +63,23 @@ def test_utf8_stream_info_skips_non_utf8_text():
|
||||
latin1 = "café".encode("latin-1") # 0xe9, invalid as standalone UTF-8
|
||||
|
||||
assert MarkitdownParser._utf8_stream_info(latin1, "a.txt") is None
|
||||
|
||||
|
||||
def test_utf8_stream_info_accepts_non_bytes_buffer():
|
||||
"""file_data may arrive as a buffer-protocol object that is not a Python
|
||||
``bytes`` (e.g. a memoryview or a native/Rust-backed buffer) and therefore
|
||||
has no ``.decode``. The UTF-8 probe must coerce via ``bytes()`` instead of
|
||||
assuming concrete ``bytes``, else every text file fails to parse with
|
||||
``'...' object has no attribute 'decode'``.
|
||||
"""
|
||||
# memoryview is a buffer-protocol object with no ``.decode`` and, unlike a
|
||||
# PEP 688 ``__buffer__`` class, ``bytes(memoryview)`` works on every
|
||||
# supported Python version — a portable stand-in for the native buffer the
|
||||
# storage layer returns.
|
||||
buf = memoryview("über".encode("utf-8"))
|
||||
assert not hasattr(buf, "decode") # precondition: would hit the original AttributeError
|
||||
|
||||
info = MarkitdownParser._utf8_stream_info(buf, "a.txt")
|
||||
|
||||
assert info is not None
|
||||
assert info.charset == "utf-8"
|
||||
|
||||
@@ -6,6 +6,7 @@ These tests cover the move semantics, lossless revert (incl. entity
|
||||
associations), edit, the guards, listing, and recall exclusion.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -21,21 +22,29 @@ from hindsight_api.engine.retain import embedding_processing
|
||||
|
||||
|
||||
async def _insert_memory(
|
||||
conn, memory: MemoryEngine, bank_id: str, text: str, fact_type: str = "experience"
|
||||
conn,
|
||||
memory: MemoryEngine,
|
||||
bank_id: str,
|
||||
text: str,
|
||||
fact_type: str = "experience",
|
||||
metadata: dict | None = None,
|
||||
) -> uuid.UUID:
|
||||
"""Insert a live memory unit with a real embedding, bypassing the LLM pipeline."""
|
||||
mem_id = uuid.uuid4()
|
||||
emb = await embedding_processing.generate_embeddings_batch(memory.embeddings, [text])
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO memory_units (id, bank_id, text, fact_type, embedding, event_date, created_at, updated_at, consolidated_at)
|
||||
VALUES ($1, $2, $3, $4, $5::vector, NOW(), NOW(), NOW(), NOW())
|
||||
INSERT INTO memory_units (
|
||||
id, bank_id, text, fact_type, embedding, event_date, metadata, created_at, updated_at, consolidated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5::vector, NOW(), $6::jsonb, NOW(), NOW(), NOW())
|
||||
""",
|
||||
mem_id,
|
||||
bank_id,
|
||||
text,
|
||||
fact_type,
|
||||
str(emb[0]),
|
||||
json.dumps(metadata or {}),
|
||||
)
|
||||
return mem_id
|
||||
|
||||
@@ -101,11 +110,12 @@ async def _archive_row(conn, mem_id: uuid.UUID) -> dict | None:
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def _archive_has_embedding_column(conn) -> bool:
|
||||
async def _archive_has_column(conn, column: str) -> bool:
|
||||
return bool(
|
||||
await conn.fetchval(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = 'invalidated_memory_units' AND column_name = 'embedding'"
|
||||
"WHERE table_name = 'invalidated_memory_units' AND column_name = $1",
|
||||
column,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -174,9 +184,12 @@ class TestInvalidate:
|
||||
arch = await _archive_row(conn, m1)
|
||||
assert arch is not None, "row must be in the archive"
|
||||
assert arch["invalidation_reason"] == "decommissioned"
|
||||
assert not await _archive_has_embedding_column(conn), (
|
||||
assert not await _archive_has_column(conn, "embedding"), (
|
||||
"archive is cold storage; the schema drops the embedding column (#2209)"
|
||||
)
|
||||
assert not await _archive_has_column(conn, "search_vector"), (
|
||||
"archive is cold storage with no index; the schema drops search_vector (#2503)"
|
||||
)
|
||||
assert await _link_count(conn, m1) == 0, "links cascade-pruned on move"
|
||||
assert str(obs_id) not in await _obs_ids(conn, bank_id), "derived observation removed"
|
||||
assert await _consolidated_at(conn, m2) is None, "surviving source reset for re-consolidation"
|
||||
@@ -216,6 +229,10 @@ class TestInvalidate:
|
||||
assert e1 in await _entity_ids_for(conn, m1), "entity associations restored on revert"
|
||||
reverted_emb = await conn.fetchval("SELECT embedding FROM memory_units WHERE id = $1", m1)
|
||||
assert reverted_emb is not None, "embedding recomputed on revert (archive keeps none)"
|
||||
# Native backend (test default) stores a real tsvector; it must be rebuilt on
|
||||
# revert so the reverted fact is keyword-searchable again (archive keeps none, #2503).
|
||||
reverted_sv = await conn.fetchval("SELECT search_vector FROM memory_units WHERE id = $1", m1)
|
||||
assert reverted_sv is not None, "search_vector recomputed on revert (archive keeps none)"
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@@ -261,6 +278,10 @@ class TestEdit:
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
m1 = await _insert_memory(conn, memory, bank_id, "The assistant visited Paris in 2023.")
|
||||
await conn.execute(
|
||||
"UPDATE memory_units SET search_vector = to_tsvector('english'::regconfig, text) WHERE id = $1",
|
||||
m1,
|
||||
)
|
||||
obs_id = await _insert_observation(conn, bank_id, "The assistant went to Paris.", [m1])
|
||||
|
||||
with (
|
||||
@@ -279,9 +300,17 @@ class TestEdit:
|
||||
assert result["state"] == "valid"
|
||||
async with pool.acquire() as conn:
|
||||
assert await _in_live(conn, m1), "edited row stays live"
|
||||
row = dict(await conn.fetchrow("SELECT text, consolidated_at FROM memory_units WHERE id = $1", m1))
|
||||
row = dict(
|
||||
await conn.fetchrow(
|
||||
"SELECT text, consolidated_at, search_vector::text AS search_vector "
|
||||
"FROM memory_units WHERE id = $1",
|
||||
m1,
|
||||
)
|
||||
)
|
||||
assert row["text"] == "The user visited Paris in 2023."
|
||||
assert row["consolidated_at"] is None, "edited memory re-consolidates"
|
||||
assert "'assist'" not in row["search_vector"], "old text must not stay in native FTS search_vector"
|
||||
assert "'user'" in row["search_vector"], "new text must refresh native FTS search_vector"
|
||||
assert str(obs_id) not in await _obs_ids(conn, bank_id), "stale observation re-derived"
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -472,6 +501,47 @@ class TestGuardsAndListing:
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_and_get_memory_units_include_metadata(
|
||||
self, memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
bank_id = f"test-curation-metadata-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
metadata = {"source": "slack", "channel": "engineering", "thread_id": "T123"}
|
||||
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
mem_id = await _insert_memory(conn, memory, bank_id, "Fact with metadata.", metadata=metadata)
|
||||
|
||||
live = (await memory.list_memory_units(bank_id, request_context=request_context))["items"]
|
||||
live_item = next(item for item in live if item["id"] == str(mem_id))
|
||||
assert live_item["metadata"] == metadata
|
||||
|
||||
detail = await memory.get_memory_unit(bank_id, str(mem_id), request_context=request_context)
|
||||
assert detail is not None
|
||||
assert detail["metadata"] == metadata
|
||||
|
||||
with (
|
||||
patch.object(memory, "submit_async_consolidation", new=AsyncMock()),
|
||||
patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()),
|
||||
):
|
||||
await memory.update_memory_unit(
|
||||
bank_id, str(mem_id), state="invalidated", reason="stale", request_context=request_context
|
||||
)
|
||||
|
||||
invalid = (await memory.list_memory_units(bank_id, state="invalidated", request_context=request_context))[
|
||||
"items"
|
||||
]
|
||||
assert invalid[0]["id"] == str(mem_id)
|
||||
assert invalid[0]["metadata"] == metadata
|
||||
|
||||
invalid_detail = await memory.get_memory_unit(bank_id, str(mem_id), request_context=request_context)
|
||||
assert invalid_detail is not None
|
||||
assert invalid_detail["state"] == "invalidated"
|
||||
assert invalid_detail["metadata"] == metadata
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_filters_by_document(self, memory: MemoryEngine, request_context: RequestContext):
|
||||
bank_id = f"test-curation-doc-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
|
||||
|
||||
|
||||
def _make_minimax(extra_body=None) -> OpenAICompatibleLLM:
|
||||
return OpenAICompatibleLLM(
|
||||
provider="minimax",
|
||||
api_key="test-key",
|
||||
base_url="",
|
||||
model="MiniMax-M3",
|
||||
extra_body=extra_body,
|
||||
)
|
||||
|
||||
|
||||
def _text_response(content: str = "ok"):
|
||||
return SimpleNamespace(
|
||||
error=None,
|
||||
usage=None,
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
finish_reason="stop",
|
||||
message=SimpleNamespace(content=content, tool_calls=None, refusal=None),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _tool_response():
|
||||
tool_call = SimpleNamespace(
|
||||
id="call_minimax_123",
|
||||
function=SimpleNamespace(name="recall", arguments='{"query": "Project Rin"}'),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
error=None,
|
||||
usage=None,
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
finish_reason="tool_calls",
|
||||
message=SimpleNamespace(content=None, tool_calls=[tool_call], refusal=None),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_minimax_call_disables_thinking_by_default():
|
||||
llm = _make_minimax()
|
||||
llm._client.chat.completions.create = AsyncMock(return_value=_text_response())
|
||||
|
||||
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
|
||||
await llm.call(messages=[{"role": "user", "content": "hi"}], max_retries=0)
|
||||
|
||||
assert llm._client.chat.completions.create.call_args.kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_minimax_call_preserves_configured_thinking_extra_body():
|
||||
llm = _make_minimax(extra_body={"thinking": {"type": "enabled"}, "reasoning_split": True})
|
||||
llm._client.chat.completions.create = AsyncMock(return_value=_text_response())
|
||||
|
||||
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
|
||||
await llm.call(messages=[{"role": "user", "content": "hi"}], max_retries=0)
|
||||
|
||||
assert llm._client.chat.completions.create.call_args.kwargs["extra_body"] == {
|
||||
"thinking": {"type": "enabled"},
|
||||
"reasoning_split": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_minimax_tool_call_disables_thinking_by_default():
|
||||
llm = _make_minimax()
|
||||
llm._client.chat.completions.create = AsyncMock(return_value=_tool_response())
|
||||
|
||||
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
|
||||
await llm.call_with_tools(
|
||||
messages=[{"role": "user", "content": "Search memory."}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "recall",
|
||||
"description": "Recall memories",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
assert llm._client.chat.completions.create.call_args.kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
|
||||
@@ -10,12 +10,18 @@ Covers:
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.consolidation.prompts import build_batch_consolidation_prompt
|
||||
from hindsight_api.engine.prompt_utils import output_language_directive
|
||||
from hindsight_api.engine.reflect.prompts import build_final_system_prompt
|
||||
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
|
||||
from hindsight_api.engine.search import retrieval as retrieval_mod
|
||||
from hindsight_api.engine.search.retrieval import tokenize_query
|
||||
from hindsight_api.engine.sql.postgresql import PostgreSQLDialect
|
||||
|
||||
|
||||
def _baseline_config() -> MagicMock:
|
||||
@@ -165,3 +171,75 @@ def test_configurable_bm25_language_migration_chains_off_head():
|
||||
src = target.read_text()
|
||||
assert 'revision: str = "p4q5r6s7t8u9"' in src
|
||||
assert 'down_revision: str | Sequence[str] | None = "86f7a033d372"' in src
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BM25 query term cap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_postgresql_native_bm25_caps_raw_terms_preserving_order():
|
||||
query = "Alpha beta alpha, gamma delta beta epsilon"
|
||||
tokens = tokenize_query(query)
|
||||
|
||||
assert PostgreSQLDialect().prepare_bm25_text(tokens, query, max_query_terms=3) == "alpha | beta | alpha"
|
||||
|
||||
|
||||
def test_postgresql_native_bm25_zero_cap_keeps_existing_unlimited_behavior():
|
||||
query = "Alpha beta alpha"
|
||||
tokens = tokenize_query(query)
|
||||
|
||||
assert PostgreSQLDialect().prepare_bm25_text(tokens, query, max_query_terms=0) == "alpha | beta | alpha"
|
||||
|
||||
|
||||
def test_postgresql_extension_bm25_keeps_raw_query_text():
|
||||
query = "Alpha beta alpha, gamma delta beta epsilon"
|
||||
tokens = tokenize_query(query)
|
||||
|
||||
assert (
|
||||
PostgreSQLDialect().prepare_bm25_text(tokens, query, text_search_extension="vchord", max_query_terms=3) == query
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_combined_retrieval_uses_default_bm25_cap_for_legacy_config(monkeypatch):
|
||||
class FakeDialect:
|
||||
max_query_terms: int | None = None
|
||||
|
||||
def build_semantic_arm(self, **kwargs):
|
||||
return "SELECT 'semantic' AS source"
|
||||
|
||||
def build_bm25_arm(self, **kwargs):
|
||||
return "SELECT 'bm25' AS source"
|
||||
|
||||
def prepare_bm25_text(self, tokens, query_text, *, text_search_extension="native", max_query_terms=None):
|
||||
self.max_query_terms = max_query_terms
|
||||
return " | ".join(tokens)
|
||||
|
||||
class FakeConn:
|
||||
backend_type = "postgresql"
|
||||
|
||||
async def fetch(self, query, *params):
|
||||
return []
|
||||
|
||||
fake_dialect = FakeDialect()
|
||||
legacy_config = SimpleNamespace(
|
||||
semantic_min_similarity=0.0,
|
||||
bm25_min_score=0.0,
|
||||
text_search_extension="native",
|
||||
text_search_extension_native_language="english",
|
||||
)
|
||||
monkeypatch.setattr(retrieval_mod, "get_config", lambda: legacy_config)
|
||||
monkeypatch.setattr(retrieval_mod, "create_sql_dialect", lambda backend: fake_dialect)
|
||||
|
||||
result = await retrieval_mod.retrieve_semantic_bm25_combined(
|
||||
FakeConn(),
|
||||
"[0.0]",
|
||||
"alpha beta",
|
||||
"bank-1",
|
||||
["observation"],
|
||||
5,
|
||||
)
|
||||
|
||||
assert result == {"observation": ([], [])}
|
||||
assert fake_dialect.max_query_terms == 0
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"""Pure unit tests for the OKF (Open Knowledge Format) serializer.
|
||||
|
||||
These exercise hindsight_api/api/okf.py with plain dicts — no DB, no LLM — so
|
||||
they pin the OKF contract (frontmatter projection, type-from-tag, shared-tag
|
||||
graph) deterministically and fast.
|
||||
"""
|
||||
|
||||
from hindsight_api.api import okf
|
||||
|
||||
|
||||
def _mm(**overrides):
|
||||
base = {
|
||||
"id": "orders",
|
||||
"name": "Orders",
|
||||
"source_query": "What are the order facts?",
|
||||
"content": "# Orders\n\nOne row per order.",
|
||||
"tags": ["type:runbook", "sales", "revenue"],
|
||||
"last_refreshed_at": "2026-01-02T00:00:00Z",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class TestPageType:
|
||||
def test_lifts_type_from_tag_and_drops_it(self):
|
||||
pt = okf.page_type(["type:runbook", "sales", "revenue"])
|
||||
assert pt.type == "runbook"
|
||||
assert pt.display_tags == ["sales", "revenue"]
|
||||
|
||||
def test_defaults_when_no_type_tag(self):
|
||||
pt = okf.page_type(["sales"])
|
||||
assert pt.type == okf.DEFAULT_PAGE_TYPE
|
||||
assert pt.display_tags == ["sales"]
|
||||
|
||||
def test_handles_none_and_empty(self):
|
||||
assert okf.page_type(None).type == okf.DEFAULT_PAGE_TYPE
|
||||
assert okf.page_type(None).display_tags == []
|
||||
|
||||
def test_blank_type_suffix_falls_back(self):
|
||||
pt = okf.page_type(["type:", "sales"])
|
||||
assert pt.type == okf.DEFAULT_PAGE_TYPE
|
||||
# the (blank) type tag is still stripped from display tags
|
||||
assert pt.display_tags == ["sales"]
|
||||
|
||||
def test_first_type_tag_wins(self):
|
||||
pt = okf.page_type(["type:runbook", "type:guide"])
|
||||
assert pt.type == "runbook"
|
||||
assert pt.display_tags == []
|
||||
|
||||
|
||||
class TestFrontmatter:
|
||||
def test_projects_expected_fields(self):
|
||||
fm = okf.frontmatter(_mm())
|
||||
assert fm["id"] == "orders"
|
||||
assert fm["type"] == "runbook"
|
||||
assert fm["title"] == "Orders"
|
||||
assert fm["description"] == "What are the order facts?"
|
||||
assert fm["tags"] == ["sales", "revenue"]
|
||||
assert fm["timestamp"] == "2026-01-02T00:00:00Z"
|
||||
|
||||
def test_timestamp_falls_back_to_created_at(self):
|
||||
fm = okf.frontmatter(_mm(last_refreshed_at=None))
|
||||
assert fm["timestamp"] == "2026-01-01T00:00:00Z"
|
||||
|
||||
def test_render_omits_none_and_empty(self):
|
||||
rendered = okf.render_frontmatter({"type": "x", "title": None, "tags": []})
|
||||
assert "title" not in rendered
|
||||
assert "tags" not in rendered
|
||||
assert 'type: "x"' in rendered
|
||||
|
||||
def test_render_quotes_and_escapes(self):
|
||||
# A name that looks like a YAML bool / contains a quote must stay a string.
|
||||
rendered = okf.render_frontmatter({"title": 'true "x"'})
|
||||
assert 'title: "true \\"x\\""' in rendered
|
||||
|
||||
|
||||
class TestRenderDocument:
|
||||
def test_includes_frontmatter_and_body(self):
|
||||
doc = okf.render_document(_mm())
|
||||
assert doc.startswith("---\n")
|
||||
assert 'type: "runbook"' in doc
|
||||
assert "One row per order." in doc
|
||||
|
||||
def test_empty_body(self):
|
||||
doc = okf.render_document(_mm(content=""))
|
||||
assert doc.count("---") == 2
|
||||
assert doc.rstrip().endswith("---")
|
||||
|
||||
|
||||
class TestKnowledgeGraph:
|
||||
def test_edge_from_shared_tag(self):
|
||||
pages = [
|
||||
_mm(id="orders", tags=["type:runbook", "sales", "revenue"]),
|
||||
_mm(id="customers", tags=["sales", "crm"]),
|
||||
_mm(id="lonely", tags=[]),
|
||||
]
|
||||
graph = okf.knowledge_graph(pages)
|
||||
assert len(graph.nodes) == 3
|
||||
assert len(graph.edges) == 1
|
||||
edge = graph.edges[0]["data"]
|
||||
assert {edge["source"], edge["target"]} == {"orders", "customers"}
|
||||
assert edge["sharedTags"] == ["sales"]
|
||||
assert edge["weight"] == 1
|
||||
|
||||
def test_type_tag_does_not_create_edges(self):
|
||||
# Two pages sharing only a type: tag must NOT be linked.
|
||||
pages = [
|
||||
_mm(id="a", tags=["type:runbook"]),
|
||||
_mm(id="b", tags=["type:runbook"]),
|
||||
]
|
||||
graph = okf.knowledge_graph(pages)
|
||||
assert graph.edges == []
|
||||
|
||||
def test_node_carries_type_and_color(self):
|
||||
graph = okf.knowledge_graph([_mm(id="orders", tags=["type:runbook", "sales"])])
|
||||
node = graph.nodes[0]["data"]
|
||||
assert node["type"] == "runbook"
|
||||
assert node["label"] == "Orders"
|
||||
assert node["tagCount"] == 1
|
||||
assert node["color"].startswith("#")
|
||||
|
||||
def test_weight_counts_shared_tags(self):
|
||||
pages = [
|
||||
_mm(id="a", tags=["sales", "revenue", "x"]),
|
||||
_mm(id="b", tags=["sales", "revenue", "y"]),
|
||||
]
|
||||
graph = okf.knowledge_graph(pages)
|
||||
assert graph.edges[0]["data"]["weight"] == 2
|
||||
assert graph.edges[0]["data"]["sharedTags"] == ["revenue", "sales"]
|
||||
|
||||
|
||||
class TestReservedFiles:
|
||||
def test_index_links_each_page(self):
|
||||
index = okf.render_index([_mm(id="orders", name="Orders", source_query="q?")])
|
||||
assert "[Orders](./orders.md)" in index
|
||||
assert "q?" in index
|
||||
assert 'type: "index"' in index
|
||||
|
||||
def test_index_empty(self):
|
||||
assert "No knowledge pages yet" in okf.render_index([])
|
||||
|
||||
def test_index_nests_folders(self):
|
||||
nodes = [
|
||||
{"id": "f1", "kind": "folder", "name": "Runbooks", "parent_id": None},
|
||||
{"id": "p1", "kind": "page", "name": "Orders", "parent_id": "f1", "source_query": "q?"},
|
||||
{"id": "p2", "kind": "page", "name": "Loose", "parent_id": None},
|
||||
]
|
||||
idx = okf.render_index(nodes)
|
||||
assert "**Runbooks/**" in idx
|
||||
# the page nested in the folder is indented and links to its file
|
||||
assert " - [Orders](./p1.md) — q?" in idx
|
||||
assert "- [Loose](./p2.md)" in idx
|
||||
|
||||
def test_log_renders_history_newest_first(self):
|
||||
history = [
|
||||
{"previous_content": "v2", "changed_at": "2026-01-02T00:00:00Z"},
|
||||
{"previous_content": "v1", "changed_at": "2026-01-01T00:00:00Z"},
|
||||
]
|
||||
log = okf.render_log(_mm(), history)
|
||||
assert 'type: "log"' in log
|
||||
assert log.index("2026-01-02") < log.index("2026-01-01")
|
||||
assert "v2" in log and "v1" in log
|
||||
|
||||
def test_log_empty(self):
|
||||
assert "No refresh history" in okf.render_log(_mm(), [])
|
||||
@@ -65,12 +65,14 @@ class TestRecallConfigFields:
|
||||
"""Hierarchical config fields for internal recall."""
|
||||
|
||||
def test_fields_exist_on_dataclass(self):
|
||||
from hindsight_api.config import HindsightConfig
|
||||
from hindsight_api.config import DEFAULT_BM25_MAX_QUERY_TERMS, HindsightConfig
|
||||
|
||||
names = {f.name for f in dataclasses.fields(HindsightConfig)}
|
||||
assert "recall_include_chunks" in names
|
||||
assert "recall_max_tokens" in names
|
||||
assert "recall_chunks_max_tokens" in names
|
||||
assert "bm25_max_query_terms" in names
|
||||
assert HindsightConfig.__dataclass_fields__["bm25_max_query_terms"].default == DEFAULT_BM25_MAX_QUERY_TERMS
|
||||
|
||||
def test_fields_are_configurable(self):
|
||||
from hindsight_api.config import HindsightConfig
|
||||
@@ -82,6 +84,7 @@ class TestRecallConfigFields:
|
||||
|
||||
def test_default_values(self):
|
||||
from hindsight_api.config import (
|
||||
DEFAULT_BM25_MAX_QUERY_TERMS,
|
||||
DEFAULT_RECALL_CHUNKS_MAX_TOKENS,
|
||||
DEFAULT_RECALL_INCLUDE_CHUNKS,
|
||||
DEFAULT_RECALL_MAX_TOKENS,
|
||||
@@ -90,9 +93,11 @@ class TestRecallConfigFields:
|
||||
assert DEFAULT_RECALL_INCLUDE_CHUNKS is True
|
||||
assert DEFAULT_RECALL_MAX_TOKENS == 2048
|
||||
assert DEFAULT_RECALL_CHUNKS_MAX_TOKENS == 1000
|
||||
assert DEFAULT_BM25_MAX_QUERY_TERMS == 0
|
||||
|
||||
def test_env_var_constants(self):
|
||||
from hindsight_api.config import (
|
||||
ENV_BM25_MAX_QUERY_TERMS,
|
||||
ENV_RECALL_CHUNKS_MAX_TOKENS,
|
||||
ENV_RECALL_INCLUDE_CHUNKS,
|
||||
ENV_RECALL_MAX_TOKENS,
|
||||
@@ -101,6 +106,7 @@ class TestRecallConfigFields:
|
||||
assert ENV_RECALL_INCLUDE_CHUNKS == "HINDSIGHT_API_RECALL_INCLUDE_CHUNKS"
|
||||
assert ENV_RECALL_MAX_TOKENS == "HINDSIGHT_API_RECALL_MAX_TOKENS"
|
||||
assert ENV_RECALL_CHUNKS_MAX_TOKENS == "HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS"
|
||||
assert ENV_BM25_MAX_QUERY_TERMS == "HINDSIGHT_API_BM25_MAX_QUERY_TERMS"
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
@@ -108,6 +114,7 @@ class TestRecallConfigFields:
|
||||
"HINDSIGHT_API_RECALL_INCLUDE_CHUNKS": "false",
|
||||
"HINDSIGHT_API_RECALL_MAX_TOKENS": "777",
|
||||
"HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS": "333",
|
||||
"HINDSIGHT_API_BM25_MAX_QUERY_TERMS": "24",
|
||||
},
|
||||
)
|
||||
def test_from_env_reads_overrides(self):
|
||||
@@ -117,6 +124,14 @@ class TestRecallConfigFields:
|
||||
assert config.recall_include_chunks is False
|
||||
assert config.recall_max_tokens == 777
|
||||
assert config.recall_chunks_max_tokens == 333
|
||||
assert config.bm25_max_query_terms == 24
|
||||
|
||||
@patch.dict("os.environ", {"HINDSIGHT_API_BM25_MAX_QUERY_TERMS": "-1"})
|
||||
def test_from_env_rejects_negative_bm25_max_query_terms(self):
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
with pytest.raises(ValueError, match="HINDSIGHT_API_BM25_MAX_QUERY_TERMS must be >= 0"):
|
||||
HindsightConfig.from_env()
|
||||
|
||||
|
||||
class TestMentalModelTriggerRecallFields:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for _strip_code_fences helper in OpenAI-compatible LLM provider."""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
|
||||
from hindsight_api.engine.providers.openai_compatible_llm import _strip_code_fences
|
||||
|
||||
@@ -36,6 +36,22 @@ class TestStripCodeFences:
|
||||
result = _strip_code_fences(content)
|
||||
assert '{"facts": []}' in result
|
||||
|
||||
def test_inner_backticks_preserved(self):
|
||||
"""Inner triple-backticks inside a JSON string value must not truncate the JSON.
|
||||
|
||||
Regression for the fact-extraction case where an extracted fact describes
|
||||
code-fence behavior, so the JSON payload itself contains a literal
|
||||
```` ```json ```` — the old split-based stripper matched that inner
|
||||
occurrence and cut the JSON mid-string.
|
||||
"""
|
||||
import json
|
||||
|
||||
content = '```json\n{"facts": [{"what": "the model wraps output in ```json fences"}]}\n```'
|
||||
result = _strip_code_fences(content)
|
||||
assert result == '{"facts": [{"what": "the model wraps output in ```json fences"}]}'
|
||||
parsed = json.loads(result)
|
||||
assert parsed["facts"][0]["what"] == "the model wraps output in ```json fences"
|
||||
|
||||
def test_no_fences_no_change(self):
|
||||
"""Content without any backticks passes through."""
|
||||
content = "Just some text without fences"
|
||||
@@ -53,12 +69,25 @@ class TestStripCodeFences:
|
||||
assert '"line2"' in result
|
||||
assert "```" not in result
|
||||
|
||||
def test_malformed_fence_returns_original(self):
|
||||
"""Malformed fences (missing closing) return something parseable."""
|
||||
def test_missing_closing_fence_recovers_json(self):
|
||||
"""A fence with no closing ``` still recovers the JSON via the outer-span fallback."""
|
||||
content = '```json\n{"facts": []}'
|
||||
result = _strip_code_fences(content)
|
||||
# Should attempt to strip and return best effort
|
||||
assert json.loads(result) == {"facts": []}
|
||||
|
||||
def test_prose_wrapped_json_recovered(self):
|
||||
"""JSON surrounded by prose (no usable fence) is recovered by the fallback."""
|
||||
content = 'Sure! Here is the result:\n{"facts": [{"what": "x"}]}\nLet me know if that helps.'
|
||||
result = _strip_code_fences(content)
|
||||
assert json.loads(result) == {"facts": [{"what": "x"}]}
|
||||
|
||||
def test_non_json_fence_left_for_retry(self):
|
||||
"""A fenced block that is not JSON yields no valid candidate; content is returned unchanged."""
|
||||
content = "```\nnot json at all\n```"
|
||||
result = _strip_code_fences(content)
|
||||
# No parseable JSON anywhere -> caller sees the stripped body (still a str), never crashes.
|
||||
assert isinstance(result, str)
|
||||
assert "not json at all" in result
|
||||
|
||||
def test_minimax_style_response(self):
|
||||
"""Real-world MiniMax response format."""
|
||||
|
||||
@@ -10,6 +10,26 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const DEFAULT_CLI_USER_AGENT: &str = concat!("hindsight-cli/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
fn default_headers(api_key: Option<&str>) -> Result<reqwest::header::HeaderMap> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
reqwest::header::HeaderValue::from_static(DEFAULT_CLI_USER_AGENT),
|
||||
);
|
||||
|
||||
if let Some(key) = api_key {
|
||||
let auth_value = format!("Bearer {}", key);
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
reqwest::header::HeaderValue::from_str(&auth_value)?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
/// Convert a progenitor client error into an anyhow error that includes the
|
||||
/// HTTP response body. Without this, errors render as
|
||||
/// "Unexpected Response: Response { ... }" with no body, hiding validation
|
||||
@@ -112,15 +132,7 @@ impl ApiClient {
|
||||
let mut client_builder =
|
||||
reqwest::Client::builder().timeout(std::time::Duration::from_secs(120));
|
||||
|
||||
if let Some(key) = api_key {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
let auth_value = format!("Bearer {}", key);
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
reqwest::header::HeaderValue::from_str(&auth_value)?,
|
||||
);
|
||||
client_builder = client_builder.default_headers(headers);
|
||||
}
|
||||
client_builder = client_builder.default_headers(default_headers(api_key.as_deref())?);
|
||||
|
||||
let http_client = client_builder.build()?;
|
||||
|
||||
@@ -1309,6 +1321,31 @@ pub use types::{
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_headers_set_cli_user_agent_without_api_key() {
|
||||
let headers = default_headers(None).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
headers.get(reqwest::header::USER_AGENT).unwrap(),
|
||||
DEFAULT_CLI_USER_AGENT,
|
||||
);
|
||||
assert!(!headers.contains_key(reqwest::header::AUTHORIZATION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_headers_keep_authorization_with_cli_user_agent() {
|
||||
let headers = default_headers(Some("hsk_test")).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
headers.get(reqwest::header::USER_AGENT).unwrap(),
|
||||
DEFAULT_CLI_USER_AGENT,
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get(reqwest::header::AUTHORIZATION).unwrap(),
|
||||
"Bearer hsk_test",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operation_deserialize() {
|
||||
let json = r#"{
|
||||
|
||||
@@ -1382,348 +1382,6 @@ paths:
|
||||
summary: Clear mental model content
|
||||
tags:
|
||||
- Mental Models
|
||||
/v1/default/banks/{bank_id}/knowledge-base/tree:
|
||||
get:
|
||||
description: Return the knowledge base as a nested tree of folders and pages.
|
||||
operationId: get_knowledge_base_tree
|
||||
parameters:
|
||||
- explode: false
|
||||
in: path
|
||||
name: bank_id
|
||||
required: true
|
||||
schema:
|
||||
title: Bank Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: header
|
||||
name: authorization
|
||||
required: false
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
style: simple
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/KnowledgeTreeResponse'
|
||||
description: Successful Response
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
description: Validation Error
|
||||
summary: Get the knowledge-base tree
|
||||
tags:
|
||||
- Knowledge Base
|
||||
/v1/default/banks/{bank_id}/knowledge-base/folders:
|
||||
post:
|
||||
description: "Create a folder, optionally nested under a parent folder."
|
||||
operationId: create_knowledge_folder
|
||||
parameters:
|
||||
- explode: false
|
||||
in: path
|
||||
name: bank_id
|
||||
required: true
|
||||
schema:
|
||||
title: Bank Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: header
|
||||
name: authorization
|
||||
required: false
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
style: simple
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateFolderRequest'
|
||||
required: true
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/KnowledgeNode'
|
||||
description: Successful Response
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
description: Validation Error
|
||||
summary: Create a knowledge-base folder
|
||||
tags:
|
||||
- Knowledge Base
|
||||
/v1/default/banks/{bank_id}/knowledge-base/pages:
|
||||
post:
|
||||
description: Create a page (a mental model + tree node). Content is generated
|
||||
asynchronously; use the returned operation_id to track completion.
|
||||
operationId: create_knowledge_page
|
||||
parameters:
|
||||
- explode: false
|
||||
in: path
|
||||
name: bank_id
|
||||
required: true
|
||||
schema:
|
||||
title: Bank Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: header
|
||||
name: authorization
|
||||
required: false
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
style: simple
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreatePageRequest'
|
||||
required: true
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateKnowledgePageResponse'
|
||||
description: Successful Response
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
description: Validation Error
|
||||
summary: Create a knowledge-base page
|
||||
tags:
|
||||
- Knowledge Base
|
||||
/v1/default/banks/{bank_id}/knowledge-base/graph:
|
||||
get:
|
||||
description: "Return pages as nodes linked by shared tags, for the constellation\
|
||||
\ view."
|
||||
operationId: get_knowledge_base_graph
|
||||
parameters:
|
||||
- explode: false
|
||||
in: path
|
||||
name: bank_id
|
||||
required: true
|
||||
schema:
|
||||
title: Bank Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: header
|
||||
name: authorization
|
||||
required: false
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
style: simple
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/KnowledgePageGraphResponse'
|
||||
description: Successful Response
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
description: Validation Error
|
||||
summary: Knowledge-base constellation graph
|
||||
tags:
|
||||
- Knowledge Base
|
||||
/v1/default/banks/{bank_id}/knowledge-base/export:
|
||||
get:
|
||||
description: "Return a portable OKF bundle: a nested index.md, one <id>.md per\
|
||||
\ page, and history logs."
|
||||
operationId: export_knowledge_base
|
||||
parameters:
|
||||
- explode: false
|
||||
in: path
|
||||
name: bank_id
|
||||
required: true
|
||||
schema:
|
||||
title: Bank Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: header
|
||||
name: authorization
|
||||
required: false
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
style: simple
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/KnowledgePageBundleResponse'
|
||||
description: Successful Response
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
description: Validation Error
|
||||
summary: Export the knowledge base as an OKF bundle
|
||||
tags:
|
||||
- Knowledge Base
|
||||
/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}:
|
||||
get:
|
||||
description: Return a single page as an OKF document (frontmatter + markdown
|
||||
body).
|
||||
operationId: get_knowledge_page
|
||||
parameters:
|
||||
- explode: false
|
||||
in: path
|
||||
name: bank_id
|
||||
required: true
|
||||
schema:
|
||||
title: Bank Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: path
|
||||
name: page_id
|
||||
required: true
|
||||
schema:
|
||||
title: Page Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: header
|
||||
name: authorization
|
||||
required: false
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
style: simple
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/KnowledgePageResponse'
|
||||
description: Successful Response
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
description: Validation Error
|
||||
summary: Get a knowledge-base page
|
||||
tags:
|
||||
- Knowledge Base
|
||||
/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}:
|
||||
delete:
|
||||
description: Delete a folder or page and its whole subtree (pages' mental models
|
||||
are removed too).
|
||||
operationId: delete_knowledge_node
|
||||
parameters:
|
||||
- explode: false
|
||||
in: path
|
||||
name: bank_id
|
||||
required: true
|
||||
schema:
|
||||
title: Bank Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: path
|
||||
name: node_id
|
||||
required: true
|
||||
schema:
|
||||
title: Node Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: header
|
||||
name: authorization
|
||||
required: false
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
style: simple
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
description: Successful Response
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
description: Validation Error
|
||||
summary: Delete a knowledge-base node
|
||||
tags:
|
||||
- Knowledge Base
|
||||
patch:
|
||||
description: "Rename a node (set `name`) and/or move it under another folder\
|
||||
\ (set `parent_id`, null for the root)."
|
||||
operationId: update_knowledge_node
|
||||
parameters:
|
||||
- explode: false
|
||||
in: path
|
||||
name: bank_id
|
||||
required: true
|
||||
schema:
|
||||
title: Bank Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: path
|
||||
name: node_id
|
||||
required: true
|
||||
schema:
|
||||
title: Node Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: header
|
||||
name: authorization
|
||||
required: false
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
style: simple
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateNodeRequest'
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/KnowledgeNode'
|
||||
description: Successful Response
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
description: Validation Error
|
||||
summary: Rename or move a knowledge-base node
|
||||
tags:
|
||||
- Knowledge Base
|
||||
/v1/default/banks/{bank_id}/directives:
|
||||
get:
|
||||
description: List hard rules that are injected into prompts.
|
||||
@@ -5437,42 +5095,6 @@ components:
|
||||
- content
|
||||
- name
|
||||
title: CreateDirectiveRequest
|
||||
CreateFolderRequest:
|
||||
description: Create a folder under an optional parent folder.
|
||||
example:
|
||||
parent_id: parent_id
|
||||
name: name
|
||||
properties:
|
||||
name:
|
||||
title: Name
|
||||
type: string
|
||||
parent_id:
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
title: CreateFolderRequest
|
||||
CreateKnowledgePageResponse:
|
||||
description: "Result of creating a page: the node id, its mental model, and\
|
||||
\ the refresh op."
|
||||
example:
|
||||
page_id: page_id
|
||||
operation_id: operation_id
|
||||
mental_model_id: mental_model_id
|
||||
properties:
|
||||
page_id:
|
||||
title: Page Id
|
||||
type: string
|
||||
mental_model_id:
|
||||
title: Mental Model Id
|
||||
type: string
|
||||
operation_id:
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- mental_model_id
|
||||
- page_id
|
||||
title: CreateKnowledgePageResponse
|
||||
CreateMentalModelRequest:
|
||||
description: Request model for creating a mental model.
|
||||
example:
|
||||
@@ -5531,64 +5153,6 @@ components:
|
||||
required:
|
||||
- operation_id
|
||||
title: CreateMentalModelResponse
|
||||
CreatePageRequest:
|
||||
description: Create a page (a mental model + tree node) under an optional parent
|
||||
folder.
|
||||
example:
|
||||
source_query: source_query
|
||||
max_tokens: 0
|
||||
parent_id: parent_id
|
||||
name: name
|
||||
trigger:
|
||||
mode: full
|
||||
refresh_after_consolidation: false
|
||||
recall_chunks_max_tokens: 1
|
||||
tag_groups:
|
||||
- match: any_strict
|
||||
tags:
|
||||
- tags
|
||||
- tags
|
||||
- match: any_strict
|
||||
tags:
|
||||
- tags
|
||||
- tags
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
include_chunks: true
|
||||
tags_match: any
|
||||
exclude_mental_models: false
|
||||
recall_max_tokens: 6
|
||||
tags:
|
||||
- tags
|
||||
- tags
|
||||
properties:
|
||||
name:
|
||||
title: Name
|
||||
type: string
|
||||
source_query:
|
||||
title: Source Query
|
||||
type: string
|
||||
parent_id:
|
||||
nullable: true
|
||||
type: string
|
||||
tags:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
max_tokens:
|
||||
nullable: true
|
||||
type: integer
|
||||
trigger:
|
||||
$ref: '#/components/schemas/MentalModelTrigger-Input'
|
||||
required:
|
||||
- name
|
||||
- source_query
|
||||
title: CreatePageRequest
|
||||
CreateWebhookRequest:
|
||||
description: Request model for registering a webhook.
|
||||
example:
|
||||
@@ -6392,226 +5956,6 @@ components:
|
||||
source_facts:
|
||||
$ref: '#/components/schemas/SourceFactsIncludeOptions'
|
||||
title: IncludeOptions
|
||||
KnowledgeNode:
|
||||
description: |-
|
||||
A node in the knowledge-base tree — a folder or a page.
|
||||
|
||||
Pages carry ``description``/``tags`` from their backing mental model. The
|
||||
knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node
|
||||
as system-owned vs. hand-authored.
|
||||
example:
|
||||
children:
|
||||
- null
|
||||
- null
|
||||
kind: folder
|
||||
parent_id: parent_id
|
||||
managed: false
|
||||
name: name
|
||||
description: description
|
||||
id: id
|
||||
mental_model_id: mental_model_id
|
||||
tags:
|
||||
- tags
|
||||
- tags
|
||||
timestamp: timestamp
|
||||
properties:
|
||||
id:
|
||||
title: Id
|
||||
type: string
|
||||
kind:
|
||||
enum:
|
||||
- folder
|
||||
- page
|
||||
title: Kind
|
||||
type: string
|
||||
name:
|
||||
title: Name
|
||||
type: string
|
||||
parent_id:
|
||||
nullable: true
|
||||
type: string
|
||||
mental_model_id:
|
||||
nullable: true
|
||||
type: string
|
||||
managed:
|
||||
default: false
|
||||
description: "Client-set flag: true = system-owned, false = hand-authored."
|
||||
title: Managed
|
||||
type: boolean
|
||||
description:
|
||||
nullable: true
|
||||
type: string
|
||||
tags:
|
||||
default: []
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
timestamp:
|
||||
nullable: true
|
||||
type: string
|
||||
children:
|
||||
default: []
|
||||
items:
|
||||
$ref: '#/components/schemas/KnowledgeNode'
|
||||
type: array
|
||||
required:
|
||||
- id
|
||||
- kind
|
||||
- name
|
||||
title: KnowledgeNode
|
||||
KnowledgePageBundleFile:
|
||||
description: One file in a portable OKF bundle.
|
||||
example:
|
||||
path: path
|
||||
content: content
|
||||
properties:
|
||||
path:
|
||||
title: Path
|
||||
type: string
|
||||
content:
|
||||
title: Content
|
||||
type: string
|
||||
required:
|
||||
- content
|
||||
- path
|
||||
title: KnowledgePageBundleFile
|
||||
KnowledgePageBundleResponse:
|
||||
description: A portable OKF bundle — a flat set of markdown files (index + pages
|
||||
+ logs).
|
||||
example:
|
||||
files:
|
||||
- path: path
|
||||
content: content
|
||||
- path: path
|
||||
content: content
|
||||
properties:
|
||||
files:
|
||||
items:
|
||||
$ref: '#/components/schemas/KnowledgePageBundleFile'
|
||||
type: array
|
||||
required:
|
||||
- files
|
||||
title: KnowledgePageBundleResponse
|
||||
KnowledgePageGraphResponse:
|
||||
description: Constellation graph of knowledge pages linked by shared tags.
|
||||
example:
|
||||
total_edges: 6
|
||||
nodes:
|
||||
- key: ""
|
||||
- key: ""
|
||||
edges:
|
||||
- key: ""
|
||||
- key: ""
|
||||
total_pages: 0
|
||||
properties:
|
||||
nodes:
|
||||
items:
|
||||
additionalProperties: {}
|
||||
type: array
|
||||
edges:
|
||||
items:
|
||||
additionalProperties: {}
|
||||
type: array
|
||||
total_pages:
|
||||
title: Total Pages
|
||||
type: integer
|
||||
total_edges:
|
||||
title: Total Edges
|
||||
type: integer
|
||||
required:
|
||||
- edges
|
||||
- nodes
|
||||
- total_edges
|
||||
- total_pages
|
||||
title: KnowledgePageGraphResponse
|
||||
KnowledgePageResponse:
|
||||
description: A knowledge page rendered as an OKF document.
|
||||
example:
|
||||
name: name
|
||||
markdown: markdown
|
||||
description: description
|
||||
id: id
|
||||
type: type
|
||||
body: body
|
||||
tags:
|
||||
- tags
|
||||
- tags
|
||||
timestamp: timestamp
|
||||
properties:
|
||||
id:
|
||||
title: Id
|
||||
type: string
|
||||
name:
|
||||
title: Name
|
||||
type: string
|
||||
type:
|
||||
description: "OKF document type — from a `type:<x>` tag, else 'knowledge-page'."
|
||||
title: Type
|
||||
type: string
|
||||
description:
|
||||
nullable: true
|
||||
type: string
|
||||
tags:
|
||||
default: []
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
timestamp:
|
||||
nullable: true
|
||||
type: string
|
||||
body:
|
||||
nullable: true
|
||||
type: string
|
||||
markdown:
|
||||
description: "The full OKF document: YAML frontmatter + markdown body."
|
||||
title: Markdown
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- markdown
|
||||
- name
|
||||
- type
|
||||
title: KnowledgePageResponse
|
||||
KnowledgeTreeResponse:
|
||||
description: The knowledge base as a nested folder/page tree.
|
||||
example:
|
||||
roots:
|
||||
- children:
|
||||
- null
|
||||
- null
|
||||
kind: folder
|
||||
parent_id: parent_id
|
||||
managed: false
|
||||
name: name
|
||||
description: description
|
||||
id: id
|
||||
mental_model_id: mental_model_id
|
||||
tags:
|
||||
- tags
|
||||
- tags
|
||||
timestamp: timestamp
|
||||
- children:
|
||||
- null
|
||||
- null
|
||||
kind: folder
|
||||
parent_id: parent_id
|
||||
managed: false
|
||||
name: name
|
||||
description: description
|
||||
id: id
|
||||
mental_model_id: mental_model_id
|
||||
tags:
|
||||
- tags
|
||||
- tags
|
||||
timestamp: timestamp
|
||||
properties:
|
||||
roots:
|
||||
items:
|
||||
$ref: '#/components/schemas/KnowledgeNode'
|
||||
type: array
|
||||
required:
|
||||
- roots
|
||||
title: KnowledgeTreeResponse
|
||||
LLMRequestEntry:
|
||||
description: "A single LLM request trace row, as returned by the read API."
|
||||
example:
|
||||
@@ -6995,6 +6339,9 @@ components:
|
||||
date: 2024-01-15T10:30:00Z
|
||||
entities: "Alice (PERSON), Google (ORGANIZATION)"
|
||||
id: 550e8400-e29b-41d4-a716-446655440000
|
||||
metadata:
|
||||
channel: engineering
|
||||
source: slack
|
||||
text: Alice works at Google on the AI team
|
||||
type: world
|
||||
limit: 100
|
||||
@@ -7397,29 +6744,6 @@ components:
|
||||
title: MentalModelResponse
|
||||
MentalModelTrigger-Input:
|
||||
description: Trigger settings for a mental model.
|
||||
example:
|
||||
mode: full
|
||||
refresh_after_consolidation: false
|
||||
recall_chunks_max_tokens: 1
|
||||
tag_groups:
|
||||
- match: any_strict
|
||||
tags:
|
||||
- tags
|
||||
- tags
|
||||
- match: any_strict
|
||||
tags:
|
||||
- tags
|
||||
- tags
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
include_chunks: true
|
||||
tags_match: any
|
||||
exclude_mental_models: false
|
||||
recall_max_tokens: 6
|
||||
properties:
|
||||
mode:
|
||||
default: full
|
||||
@@ -8793,19 +8117,6 @@ components:
|
||||
trigger:
|
||||
$ref: '#/components/schemas/MentalModelTrigger-Input'
|
||||
title: UpdateMentalModelRequest
|
||||
UpdateNodeRequest:
|
||||
description: Rename and/or move a node. Each field applies only when present.
|
||||
example:
|
||||
parent_id: parent_id
|
||||
name: name
|
||||
properties:
|
||||
name:
|
||||
nullable: true
|
||||
type: string
|
||||
parent_id:
|
||||
nullable: true
|
||||
type: string
|
||||
title: UpdateNodeRequest
|
||||
UpdateWebhookRequest:
|
||||
description: Request model for updating a webhook. Only provided fields are
|
||||
updated.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,8 +65,6 @@ type APIClient struct {
|
||||
|
||||
FilesAPI *FilesAPIService
|
||||
|
||||
KnowledgeBaseAPI *KnowledgeBaseAPIService
|
||||
|
||||
LLMTracesAPI *LLMTracesAPIService
|
||||
|
||||
MemoryAPI *MemoryAPIService
|
||||
@@ -104,7 +102,6 @@ func NewAPIClient(cfg *Configuration) *APIClient {
|
||||
c.DocumentsAPI = (*DocumentsAPIService)(&c.common)
|
||||
c.EntitiesAPI = (*EntitiesAPIService)(&c.common)
|
||||
c.FilesAPI = (*FilesAPIService)(&c.common)
|
||||
c.KnowledgeBaseAPI = (*KnowledgeBaseAPIService)(&c.common)
|
||||
c.LLMTracesAPI = (*LLMTracesAPIService)(&c.common)
|
||||
c.MemoryAPI = (*MemoryAPIService)(&c.common)
|
||||
c.MentalModelsAPI = (*MentalModelsAPIService)(&c.common)
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.8.3
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// checks if the CreateFolderRequest type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &CreateFolderRequest{}
|
||||
|
||||
// CreateFolderRequest Create a folder under an optional parent folder.
|
||||
type CreateFolderRequest struct {
|
||||
Name string `json:"name"`
|
||||
ParentId NullableString `json:"parent_id,omitempty"`
|
||||
}
|
||||
|
||||
type _CreateFolderRequest CreateFolderRequest
|
||||
|
||||
// NewCreateFolderRequest instantiates a new CreateFolderRequest object
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewCreateFolderRequest(name string) *CreateFolderRequest {
|
||||
this := CreateFolderRequest{}
|
||||
this.Name = name
|
||||
return &this
|
||||
}
|
||||
|
||||
// NewCreateFolderRequestWithDefaults instantiates a new CreateFolderRequest object
|
||||
// This constructor will only assign default values to properties that have it defined,
|
||||
// but it doesn't guarantee that properties required by API are set
|
||||
func NewCreateFolderRequestWithDefaults() *CreateFolderRequest {
|
||||
this := CreateFolderRequest{}
|
||||
return &this
|
||||
}
|
||||
|
||||
// GetName returns the Name field value
|
||||
func (o *CreateFolderRequest) GetName() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Name
|
||||
}
|
||||
|
||||
// GetNameOk returns a tuple with the Name field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *CreateFolderRequest) GetNameOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Name, true
|
||||
}
|
||||
|
||||
// SetName sets field value
|
||||
func (o *CreateFolderRequest) SetName(v string) {
|
||||
o.Name = v
|
||||
}
|
||||
|
||||
// GetParentId returns the ParentId field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *CreateFolderRequest) GetParentId() string {
|
||||
if o == nil || IsNil(o.ParentId.Get()) {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
return *o.ParentId.Get()
|
||||
}
|
||||
|
||||
// GetParentIdOk returns a tuple with the ParentId 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 *CreateFolderRequest) GetParentIdOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.ParentId.Get(), o.ParentId.IsSet()
|
||||
}
|
||||
|
||||
// HasParentId returns a boolean if a field has been set.
|
||||
func (o *CreateFolderRequest) HasParentId() bool {
|
||||
if o != nil && o.ParentId.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetParentId gets a reference to the given NullableString and assigns it to the ParentId field.
|
||||
func (o *CreateFolderRequest) SetParentId(v string) {
|
||||
o.ParentId.Set(&v)
|
||||
}
|
||||
// SetParentIdNil sets the value for ParentId to be an explicit nil
|
||||
func (o *CreateFolderRequest) SetParentIdNil() {
|
||||
o.ParentId.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetParentId ensures that no value is present for ParentId, not even an explicit nil
|
||||
func (o *CreateFolderRequest) UnsetParentId() {
|
||||
o.ParentId.Unset()
|
||||
}
|
||||
|
||||
func (o CreateFolderRequest) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return json.Marshal(toSerialize)
|
||||
}
|
||||
|
||||
func (o CreateFolderRequest) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
toSerialize["name"] = o.Name
|
||||
if o.ParentId.IsSet() {
|
||||
toSerialize["parent_id"] = o.ParentId.Get()
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
func (o *CreateFolderRequest) UnmarshalJSON(data []byte) (err error) {
|
||||
// This validates that all required properties are included in the JSON object
|
||||
// by unmarshalling the object into a generic map with string keys and checking
|
||||
// that every required field exists as a key in the generic map.
|
||||
requiredProperties := []string{
|
||||
"name",
|
||||
}
|
||||
|
||||
allProperties := make(map[string]interface{})
|
||||
|
||||
err = json.Unmarshal(data, &allProperties)
|
||||
|
||||
if err != nil {
|
||||
return err;
|
||||
}
|
||||
|
||||
for _, requiredProperty := range(requiredProperties) {
|
||||
if _, exists := allProperties[requiredProperty]; !exists {
|
||||
return fmt.Errorf("no value given for required property %v", requiredProperty)
|
||||
}
|
||||
}
|
||||
|
||||
varCreateFolderRequest := _CreateFolderRequest{}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
err = decoder.Decode(&varCreateFolderRequest)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*o = CreateFolderRequest(varCreateFolderRequest)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type NullableCreateFolderRequest struct {
|
||||
value *CreateFolderRequest
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableCreateFolderRequest) Get() *CreateFolderRequest {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableCreateFolderRequest) Set(val *CreateFolderRequest) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableCreateFolderRequest) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableCreateFolderRequest) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableCreateFolderRequest(val *CreateFolderRequest) *NullableCreateFolderRequest {
|
||||
return &NullableCreateFolderRequest{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableCreateFolderRequest) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableCreateFolderRequest) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.8.3
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// checks if the CreateKnowledgePageResponse type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &CreateKnowledgePageResponse{}
|
||||
|
||||
// CreateKnowledgePageResponse Result of creating a page: the node id, its mental model, and the refresh op.
|
||||
type CreateKnowledgePageResponse struct {
|
||||
PageId string `json:"page_id"`
|
||||
MentalModelId string `json:"mental_model_id"`
|
||||
OperationId NullableString `json:"operation_id,omitempty"`
|
||||
}
|
||||
|
||||
type _CreateKnowledgePageResponse CreateKnowledgePageResponse
|
||||
|
||||
// NewCreateKnowledgePageResponse instantiates a new CreateKnowledgePageResponse object
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewCreateKnowledgePageResponse(pageId string, mentalModelId string) *CreateKnowledgePageResponse {
|
||||
this := CreateKnowledgePageResponse{}
|
||||
this.PageId = pageId
|
||||
this.MentalModelId = mentalModelId
|
||||
return &this
|
||||
}
|
||||
|
||||
// NewCreateKnowledgePageResponseWithDefaults instantiates a new CreateKnowledgePageResponse object
|
||||
// This constructor will only assign default values to properties that have it defined,
|
||||
// but it doesn't guarantee that properties required by API are set
|
||||
func NewCreateKnowledgePageResponseWithDefaults() *CreateKnowledgePageResponse {
|
||||
this := CreateKnowledgePageResponse{}
|
||||
return &this
|
||||
}
|
||||
|
||||
// GetPageId returns the PageId field value
|
||||
func (o *CreateKnowledgePageResponse) GetPageId() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.PageId
|
||||
}
|
||||
|
||||
// GetPageIdOk returns a tuple with the PageId field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *CreateKnowledgePageResponse) GetPageIdOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.PageId, true
|
||||
}
|
||||
|
||||
// SetPageId sets field value
|
||||
func (o *CreateKnowledgePageResponse) SetPageId(v string) {
|
||||
o.PageId = v
|
||||
}
|
||||
|
||||
// GetMentalModelId returns the MentalModelId field value
|
||||
func (o *CreateKnowledgePageResponse) GetMentalModelId() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.MentalModelId
|
||||
}
|
||||
|
||||
// GetMentalModelIdOk returns a tuple with the MentalModelId field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *CreateKnowledgePageResponse) GetMentalModelIdOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.MentalModelId, true
|
||||
}
|
||||
|
||||
// SetMentalModelId sets field value
|
||||
func (o *CreateKnowledgePageResponse) SetMentalModelId(v string) {
|
||||
o.MentalModelId = v
|
||||
}
|
||||
|
||||
// GetOperationId returns the OperationId field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *CreateKnowledgePageResponse) GetOperationId() string {
|
||||
if o == nil || IsNil(o.OperationId.Get()) {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
return *o.OperationId.Get()
|
||||
}
|
||||
|
||||
// GetOperationIdOk returns a tuple with the OperationId 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 *CreateKnowledgePageResponse) GetOperationIdOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.OperationId.Get(), o.OperationId.IsSet()
|
||||
}
|
||||
|
||||
// HasOperationId returns a boolean if a field has been set.
|
||||
func (o *CreateKnowledgePageResponse) HasOperationId() bool {
|
||||
if o != nil && o.OperationId.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetOperationId gets a reference to the given NullableString and assigns it to the OperationId field.
|
||||
func (o *CreateKnowledgePageResponse) SetOperationId(v string) {
|
||||
o.OperationId.Set(&v)
|
||||
}
|
||||
// SetOperationIdNil sets the value for OperationId to be an explicit nil
|
||||
func (o *CreateKnowledgePageResponse) SetOperationIdNil() {
|
||||
o.OperationId.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetOperationId ensures that no value is present for OperationId, not even an explicit nil
|
||||
func (o *CreateKnowledgePageResponse) UnsetOperationId() {
|
||||
o.OperationId.Unset()
|
||||
}
|
||||
|
||||
func (o CreateKnowledgePageResponse) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return json.Marshal(toSerialize)
|
||||
}
|
||||
|
||||
func (o CreateKnowledgePageResponse) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
toSerialize["page_id"] = o.PageId
|
||||
toSerialize["mental_model_id"] = o.MentalModelId
|
||||
if o.OperationId.IsSet() {
|
||||
toSerialize["operation_id"] = o.OperationId.Get()
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
func (o *CreateKnowledgePageResponse) UnmarshalJSON(data []byte) (err error) {
|
||||
// This validates that all required properties are included in the JSON object
|
||||
// by unmarshalling the object into a generic map with string keys and checking
|
||||
// that every required field exists as a key in the generic map.
|
||||
requiredProperties := []string{
|
||||
"page_id",
|
||||
"mental_model_id",
|
||||
}
|
||||
|
||||
allProperties := make(map[string]interface{})
|
||||
|
||||
err = json.Unmarshal(data, &allProperties)
|
||||
|
||||
if err != nil {
|
||||
return err;
|
||||
}
|
||||
|
||||
for _, requiredProperty := range(requiredProperties) {
|
||||
if _, exists := allProperties[requiredProperty]; !exists {
|
||||
return fmt.Errorf("no value given for required property %v", requiredProperty)
|
||||
}
|
||||
}
|
||||
|
||||
varCreateKnowledgePageResponse := _CreateKnowledgePageResponse{}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
err = decoder.Decode(&varCreateKnowledgePageResponse)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*o = CreateKnowledgePageResponse(varCreateKnowledgePageResponse)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type NullableCreateKnowledgePageResponse struct {
|
||||
value *CreateKnowledgePageResponse
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableCreateKnowledgePageResponse) Get() *CreateKnowledgePageResponse {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableCreateKnowledgePageResponse) Set(val *CreateKnowledgePageResponse) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableCreateKnowledgePageResponse) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableCreateKnowledgePageResponse) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableCreateKnowledgePageResponse(val *CreateKnowledgePageResponse) *NullableCreateKnowledgePageResponse {
|
||||
return &NullableCreateKnowledgePageResponse{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableCreateKnowledgePageResponse) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableCreateKnowledgePageResponse) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.8.3
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// checks if the CreatePageRequest type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &CreatePageRequest{}
|
||||
|
||||
// CreatePageRequest Create a page (a mental model + tree node) under an optional parent folder.
|
||||
type CreatePageRequest struct {
|
||||
Name string `json:"name"`
|
||||
SourceQuery string `json:"source_query"`
|
||||
ParentId NullableString `json:"parent_id,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
MaxTokens NullableInt32 `json:"max_tokens,omitempty"`
|
||||
Trigger NullableMentalModelTriggerInput `json:"trigger,omitempty"`
|
||||
}
|
||||
|
||||
type _CreatePageRequest CreatePageRequest
|
||||
|
||||
// NewCreatePageRequest instantiates a new CreatePageRequest object
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewCreatePageRequest(name string, sourceQuery string) *CreatePageRequest {
|
||||
this := CreatePageRequest{}
|
||||
this.Name = name
|
||||
this.SourceQuery = sourceQuery
|
||||
return &this
|
||||
}
|
||||
|
||||
// NewCreatePageRequestWithDefaults instantiates a new CreatePageRequest object
|
||||
// This constructor will only assign default values to properties that have it defined,
|
||||
// but it doesn't guarantee that properties required by API are set
|
||||
func NewCreatePageRequestWithDefaults() *CreatePageRequest {
|
||||
this := CreatePageRequest{}
|
||||
return &this
|
||||
}
|
||||
|
||||
// GetName returns the Name field value
|
||||
func (o *CreatePageRequest) GetName() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Name
|
||||
}
|
||||
|
||||
// GetNameOk returns a tuple with the Name field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *CreatePageRequest) GetNameOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Name, true
|
||||
}
|
||||
|
||||
// SetName sets field value
|
||||
func (o *CreatePageRequest) SetName(v string) {
|
||||
o.Name = v
|
||||
}
|
||||
|
||||
// GetSourceQuery returns the SourceQuery field value
|
||||
func (o *CreatePageRequest) GetSourceQuery() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.SourceQuery
|
||||
}
|
||||
|
||||
// GetSourceQueryOk returns a tuple with the SourceQuery field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *CreatePageRequest) GetSourceQueryOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.SourceQuery, true
|
||||
}
|
||||
|
||||
// SetSourceQuery sets field value
|
||||
func (o *CreatePageRequest) SetSourceQuery(v string) {
|
||||
o.SourceQuery = v
|
||||
}
|
||||
|
||||
// GetParentId returns the ParentId field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *CreatePageRequest) GetParentId() string {
|
||||
if o == nil || IsNil(o.ParentId.Get()) {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
return *o.ParentId.Get()
|
||||
}
|
||||
|
||||
// GetParentIdOk returns a tuple with the ParentId 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 *CreatePageRequest) GetParentIdOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.ParentId.Get(), o.ParentId.IsSet()
|
||||
}
|
||||
|
||||
// HasParentId returns a boolean if a field has been set.
|
||||
func (o *CreatePageRequest) HasParentId() bool {
|
||||
if o != nil && o.ParentId.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetParentId gets a reference to the given NullableString and assigns it to the ParentId field.
|
||||
func (o *CreatePageRequest) SetParentId(v string) {
|
||||
o.ParentId.Set(&v)
|
||||
}
|
||||
// SetParentIdNil sets the value for ParentId to be an explicit nil
|
||||
func (o *CreatePageRequest) SetParentIdNil() {
|
||||
o.ParentId.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetParentId ensures that no value is present for ParentId, not even an explicit nil
|
||||
func (o *CreatePageRequest) UnsetParentId() {
|
||||
o.ParentId.Unset()
|
||||
}
|
||||
|
||||
// GetTags returns the Tags field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *CreatePageRequest) GetTags() []string {
|
||||
if o == nil {
|
||||
var ret []string
|
||||
return ret
|
||||
}
|
||||
return o.Tags
|
||||
}
|
||||
|
||||
// GetTagsOk returns a tuple with the Tags 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 *CreatePageRequest) GetTagsOk() ([]string, bool) {
|
||||
if o == nil || IsNil(o.Tags) {
|
||||
return nil, false
|
||||
}
|
||||
return o.Tags, true
|
||||
}
|
||||
|
||||
// HasTags returns a boolean if a field has been set.
|
||||
func (o *CreatePageRequest) HasTags() bool {
|
||||
if o != nil && !IsNil(o.Tags) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetTags gets a reference to the given []string and assigns it to the Tags field.
|
||||
func (o *CreatePageRequest) SetTags(v []string) {
|
||||
o.Tags = v
|
||||
}
|
||||
|
||||
// GetMaxTokens returns the MaxTokens field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *CreatePageRequest) GetMaxTokens() int32 {
|
||||
if o == nil || IsNil(o.MaxTokens.Get()) {
|
||||
var ret int32
|
||||
return ret
|
||||
}
|
||||
return *o.MaxTokens.Get()
|
||||
}
|
||||
|
||||
// GetMaxTokensOk returns a tuple with the MaxTokens 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 *CreatePageRequest) GetMaxTokensOk() (*int32, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.MaxTokens.Get(), o.MaxTokens.IsSet()
|
||||
}
|
||||
|
||||
// HasMaxTokens returns a boolean if a field has been set.
|
||||
func (o *CreatePageRequest) HasMaxTokens() bool {
|
||||
if o != nil && o.MaxTokens.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetMaxTokens gets a reference to the given NullableInt32 and assigns it to the MaxTokens field.
|
||||
func (o *CreatePageRequest) SetMaxTokens(v int32) {
|
||||
o.MaxTokens.Set(&v)
|
||||
}
|
||||
// SetMaxTokensNil sets the value for MaxTokens to be an explicit nil
|
||||
func (o *CreatePageRequest) SetMaxTokensNil() {
|
||||
o.MaxTokens.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetMaxTokens ensures that no value is present for MaxTokens, not even an explicit nil
|
||||
func (o *CreatePageRequest) UnsetMaxTokens() {
|
||||
o.MaxTokens.Unset()
|
||||
}
|
||||
|
||||
// GetTrigger returns the Trigger field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *CreatePageRequest) GetTrigger() MentalModelTriggerInput {
|
||||
if o == nil || IsNil(o.Trigger.Get()) {
|
||||
var ret MentalModelTriggerInput
|
||||
return ret
|
||||
}
|
||||
return *o.Trigger.Get()
|
||||
}
|
||||
|
||||
// GetTriggerOk returns a tuple with the Trigger 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 *CreatePageRequest) GetTriggerOk() (*MentalModelTriggerInput, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.Trigger.Get(), o.Trigger.IsSet()
|
||||
}
|
||||
|
||||
// HasTrigger returns a boolean if a field has been set.
|
||||
func (o *CreatePageRequest) HasTrigger() bool {
|
||||
if o != nil && o.Trigger.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetTrigger gets a reference to the given NullableMentalModelTriggerInput and assigns it to the Trigger field.
|
||||
func (o *CreatePageRequest) SetTrigger(v MentalModelTriggerInput) {
|
||||
o.Trigger.Set(&v)
|
||||
}
|
||||
// SetTriggerNil sets the value for Trigger to be an explicit nil
|
||||
func (o *CreatePageRequest) SetTriggerNil() {
|
||||
o.Trigger.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetTrigger ensures that no value is present for Trigger, not even an explicit nil
|
||||
func (o *CreatePageRequest) UnsetTrigger() {
|
||||
o.Trigger.Unset()
|
||||
}
|
||||
|
||||
func (o CreatePageRequest) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return json.Marshal(toSerialize)
|
||||
}
|
||||
|
||||
func (o CreatePageRequest) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
toSerialize["name"] = o.Name
|
||||
toSerialize["source_query"] = o.SourceQuery
|
||||
if o.ParentId.IsSet() {
|
||||
toSerialize["parent_id"] = o.ParentId.Get()
|
||||
}
|
||||
if o.Tags != nil {
|
||||
toSerialize["tags"] = o.Tags
|
||||
}
|
||||
if o.MaxTokens.IsSet() {
|
||||
toSerialize["max_tokens"] = o.MaxTokens.Get()
|
||||
}
|
||||
if o.Trigger.IsSet() {
|
||||
toSerialize["trigger"] = o.Trigger.Get()
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
func (o *CreatePageRequest) UnmarshalJSON(data []byte) (err error) {
|
||||
// This validates that all required properties are included in the JSON object
|
||||
// by unmarshalling the object into a generic map with string keys and checking
|
||||
// that every required field exists as a key in the generic map.
|
||||
requiredProperties := []string{
|
||||
"name",
|
||||
"source_query",
|
||||
}
|
||||
|
||||
allProperties := make(map[string]interface{})
|
||||
|
||||
err = json.Unmarshal(data, &allProperties)
|
||||
|
||||
if err != nil {
|
||||
return err;
|
||||
}
|
||||
|
||||
for _, requiredProperty := range(requiredProperties) {
|
||||
if _, exists := allProperties[requiredProperty]; !exists {
|
||||
return fmt.Errorf("no value given for required property %v", requiredProperty)
|
||||
}
|
||||
}
|
||||
|
||||
varCreatePageRequest := _CreatePageRequest{}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
err = decoder.Decode(&varCreatePageRequest)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*o = CreatePageRequest(varCreatePageRequest)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type NullableCreatePageRequest struct {
|
||||
value *CreatePageRequest
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableCreatePageRequest) Get() *CreatePageRequest {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableCreatePageRequest) Set(val *CreatePageRequest) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableCreatePageRequest) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableCreatePageRequest) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableCreatePageRequest(val *CreatePageRequest) *NullableCreatePageRequest {
|
||||
return &NullableCreatePageRequest{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableCreatePageRequest) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableCreatePageRequest) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,511 +0,0 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.8.3
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// checks if the KnowledgeNode type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &KnowledgeNode{}
|
||||
|
||||
// KnowledgeNode A node in the knowledge-base tree — a folder or a page. Pages carry ``description``/``tags`` from their backing mental model. The knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node as system-owned vs. hand-authored.
|
||||
type KnowledgeNode struct {
|
||||
Id string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
ParentId NullableString `json:"parent_id,omitempty"`
|
||||
MentalModelId NullableString `json:"mental_model_id,omitempty"`
|
||||
// Client-set flag: true = system-owned, false = hand-authored.
|
||||
Managed *bool `json:"managed,omitempty"`
|
||||
Description NullableString `json:"description,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Timestamp NullableString `json:"timestamp,omitempty"`
|
||||
Children []KnowledgeNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
type _KnowledgeNode KnowledgeNode
|
||||
|
||||
// NewKnowledgeNode instantiates a new KnowledgeNode object
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewKnowledgeNode(id string, kind string, name string) *KnowledgeNode {
|
||||
this := KnowledgeNode{}
|
||||
this.Id = id
|
||||
this.Kind = kind
|
||||
this.Name = name
|
||||
var managed bool = false
|
||||
this.Managed = &managed
|
||||
return &this
|
||||
}
|
||||
|
||||
// NewKnowledgeNodeWithDefaults instantiates a new KnowledgeNode object
|
||||
// This constructor will only assign default values to properties that have it defined,
|
||||
// but it doesn't guarantee that properties required by API are set
|
||||
func NewKnowledgeNodeWithDefaults() *KnowledgeNode {
|
||||
this := KnowledgeNode{}
|
||||
var managed bool = false
|
||||
this.Managed = &managed
|
||||
return &this
|
||||
}
|
||||
|
||||
// GetId returns the Id field value
|
||||
func (o *KnowledgeNode) GetId() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Id
|
||||
}
|
||||
|
||||
// GetIdOk returns a tuple with the Id field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgeNode) GetIdOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Id, true
|
||||
}
|
||||
|
||||
// SetId sets field value
|
||||
func (o *KnowledgeNode) SetId(v string) {
|
||||
o.Id = v
|
||||
}
|
||||
|
||||
// GetKind returns the Kind field value
|
||||
func (o *KnowledgeNode) GetKind() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Kind
|
||||
}
|
||||
|
||||
// GetKindOk returns a tuple with the Kind field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgeNode) GetKindOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Kind, true
|
||||
}
|
||||
|
||||
// SetKind sets field value
|
||||
func (o *KnowledgeNode) SetKind(v string) {
|
||||
o.Kind = v
|
||||
}
|
||||
|
||||
// GetName returns the Name field value
|
||||
func (o *KnowledgeNode) GetName() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Name
|
||||
}
|
||||
|
||||
// GetNameOk returns a tuple with the Name field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgeNode) GetNameOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Name, true
|
||||
}
|
||||
|
||||
// SetName sets field value
|
||||
func (o *KnowledgeNode) SetName(v string) {
|
||||
o.Name = v
|
||||
}
|
||||
|
||||
// GetParentId returns the ParentId field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *KnowledgeNode) GetParentId() string {
|
||||
if o == nil || IsNil(o.ParentId.Get()) {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
return *o.ParentId.Get()
|
||||
}
|
||||
|
||||
// GetParentIdOk returns a tuple with the ParentId 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 *KnowledgeNode) GetParentIdOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.ParentId.Get(), o.ParentId.IsSet()
|
||||
}
|
||||
|
||||
// HasParentId returns a boolean if a field has been set.
|
||||
func (o *KnowledgeNode) HasParentId() bool {
|
||||
if o != nil && o.ParentId.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetParentId gets a reference to the given NullableString and assigns it to the ParentId field.
|
||||
func (o *KnowledgeNode) SetParentId(v string) {
|
||||
o.ParentId.Set(&v)
|
||||
}
|
||||
// SetParentIdNil sets the value for ParentId to be an explicit nil
|
||||
func (o *KnowledgeNode) SetParentIdNil() {
|
||||
o.ParentId.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetParentId ensures that no value is present for ParentId, not even an explicit nil
|
||||
func (o *KnowledgeNode) UnsetParentId() {
|
||||
o.ParentId.Unset()
|
||||
}
|
||||
|
||||
// GetMentalModelId returns the MentalModelId field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *KnowledgeNode) GetMentalModelId() string {
|
||||
if o == nil || IsNil(o.MentalModelId.Get()) {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
return *o.MentalModelId.Get()
|
||||
}
|
||||
|
||||
// GetMentalModelIdOk returns a tuple with the MentalModelId 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 *KnowledgeNode) GetMentalModelIdOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.MentalModelId.Get(), o.MentalModelId.IsSet()
|
||||
}
|
||||
|
||||
// HasMentalModelId returns a boolean if a field has been set.
|
||||
func (o *KnowledgeNode) HasMentalModelId() bool {
|
||||
if o != nil && o.MentalModelId.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetMentalModelId gets a reference to the given NullableString and assigns it to the MentalModelId field.
|
||||
func (o *KnowledgeNode) SetMentalModelId(v string) {
|
||||
o.MentalModelId.Set(&v)
|
||||
}
|
||||
// SetMentalModelIdNil sets the value for MentalModelId to be an explicit nil
|
||||
func (o *KnowledgeNode) SetMentalModelIdNil() {
|
||||
o.MentalModelId.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetMentalModelId ensures that no value is present for MentalModelId, not even an explicit nil
|
||||
func (o *KnowledgeNode) UnsetMentalModelId() {
|
||||
o.MentalModelId.Unset()
|
||||
}
|
||||
|
||||
// GetManaged returns the Managed field value if set, zero value otherwise.
|
||||
func (o *KnowledgeNode) GetManaged() bool {
|
||||
if o == nil || IsNil(o.Managed) {
|
||||
var ret bool
|
||||
return ret
|
||||
}
|
||||
return *o.Managed
|
||||
}
|
||||
|
||||
// GetManagedOk returns a tuple with the Managed field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgeNode) GetManagedOk() (*bool, bool) {
|
||||
if o == nil || IsNil(o.Managed) {
|
||||
return nil, false
|
||||
}
|
||||
return o.Managed, true
|
||||
}
|
||||
|
||||
// HasManaged returns a boolean if a field has been set.
|
||||
func (o *KnowledgeNode) HasManaged() bool {
|
||||
if o != nil && !IsNil(o.Managed) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetManaged gets a reference to the given bool and assigns it to the Managed field.
|
||||
func (o *KnowledgeNode) SetManaged(v bool) {
|
||||
o.Managed = &v
|
||||
}
|
||||
|
||||
// GetDescription returns the Description field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *KnowledgeNode) GetDescription() string {
|
||||
if o == nil || IsNil(o.Description.Get()) {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
return *o.Description.Get()
|
||||
}
|
||||
|
||||
// GetDescriptionOk returns a tuple with the Description 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 *KnowledgeNode) GetDescriptionOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.Description.Get(), o.Description.IsSet()
|
||||
}
|
||||
|
||||
// HasDescription returns a boolean if a field has been set.
|
||||
func (o *KnowledgeNode) HasDescription() bool {
|
||||
if o != nil && o.Description.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetDescription gets a reference to the given NullableString and assigns it to the Description field.
|
||||
func (o *KnowledgeNode) SetDescription(v string) {
|
||||
o.Description.Set(&v)
|
||||
}
|
||||
// SetDescriptionNil sets the value for Description to be an explicit nil
|
||||
func (o *KnowledgeNode) SetDescriptionNil() {
|
||||
o.Description.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetDescription ensures that no value is present for Description, not even an explicit nil
|
||||
func (o *KnowledgeNode) UnsetDescription() {
|
||||
o.Description.Unset()
|
||||
}
|
||||
|
||||
// GetTags returns the Tags field value if set, zero value otherwise.
|
||||
func (o *KnowledgeNode) GetTags() []string {
|
||||
if o == nil || IsNil(o.Tags) {
|
||||
var ret []string
|
||||
return ret
|
||||
}
|
||||
return o.Tags
|
||||
}
|
||||
|
||||
// GetTagsOk returns a tuple with the Tags field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgeNode) GetTagsOk() ([]string, bool) {
|
||||
if o == nil || IsNil(o.Tags) {
|
||||
return nil, false
|
||||
}
|
||||
return o.Tags, true
|
||||
}
|
||||
|
||||
// HasTags returns a boolean if a field has been set.
|
||||
func (o *KnowledgeNode) HasTags() bool {
|
||||
if o != nil && !IsNil(o.Tags) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetTags gets a reference to the given []string and assigns it to the Tags field.
|
||||
func (o *KnowledgeNode) SetTags(v []string) {
|
||||
o.Tags = v
|
||||
}
|
||||
|
||||
// GetTimestamp returns the Timestamp field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *KnowledgeNode) GetTimestamp() string {
|
||||
if o == nil || IsNil(o.Timestamp.Get()) {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
return *o.Timestamp.Get()
|
||||
}
|
||||
|
||||
// GetTimestampOk returns a tuple with the Timestamp 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 *KnowledgeNode) GetTimestampOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.Timestamp.Get(), o.Timestamp.IsSet()
|
||||
}
|
||||
|
||||
// HasTimestamp returns a boolean if a field has been set.
|
||||
func (o *KnowledgeNode) HasTimestamp() bool {
|
||||
if o != nil && o.Timestamp.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetTimestamp gets a reference to the given NullableString and assigns it to the Timestamp field.
|
||||
func (o *KnowledgeNode) SetTimestamp(v string) {
|
||||
o.Timestamp.Set(&v)
|
||||
}
|
||||
// SetTimestampNil sets the value for Timestamp to be an explicit nil
|
||||
func (o *KnowledgeNode) SetTimestampNil() {
|
||||
o.Timestamp.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetTimestamp ensures that no value is present for Timestamp, not even an explicit nil
|
||||
func (o *KnowledgeNode) UnsetTimestamp() {
|
||||
o.Timestamp.Unset()
|
||||
}
|
||||
|
||||
// GetChildren returns the Children field value if set, zero value otherwise.
|
||||
func (o *KnowledgeNode) GetChildren() []KnowledgeNode {
|
||||
if o == nil || IsNil(o.Children) {
|
||||
var ret []KnowledgeNode
|
||||
return ret
|
||||
}
|
||||
return o.Children
|
||||
}
|
||||
|
||||
// GetChildrenOk returns a tuple with the Children field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgeNode) GetChildrenOk() ([]KnowledgeNode, bool) {
|
||||
if o == nil || IsNil(o.Children) {
|
||||
return nil, false
|
||||
}
|
||||
return o.Children, true
|
||||
}
|
||||
|
||||
// HasChildren returns a boolean if a field has been set.
|
||||
func (o *KnowledgeNode) HasChildren() bool {
|
||||
if o != nil && !IsNil(o.Children) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetChildren gets a reference to the given []KnowledgeNode and assigns it to the Children field.
|
||||
func (o *KnowledgeNode) SetChildren(v []KnowledgeNode) {
|
||||
o.Children = v
|
||||
}
|
||||
|
||||
func (o KnowledgeNode) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return json.Marshal(toSerialize)
|
||||
}
|
||||
|
||||
func (o KnowledgeNode) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
toSerialize["id"] = o.Id
|
||||
toSerialize["kind"] = o.Kind
|
||||
toSerialize["name"] = o.Name
|
||||
if o.ParentId.IsSet() {
|
||||
toSerialize["parent_id"] = o.ParentId.Get()
|
||||
}
|
||||
if o.MentalModelId.IsSet() {
|
||||
toSerialize["mental_model_id"] = o.MentalModelId.Get()
|
||||
}
|
||||
if !IsNil(o.Managed) {
|
||||
toSerialize["managed"] = o.Managed
|
||||
}
|
||||
if o.Description.IsSet() {
|
||||
toSerialize["description"] = o.Description.Get()
|
||||
}
|
||||
if !IsNil(o.Tags) {
|
||||
toSerialize["tags"] = o.Tags
|
||||
}
|
||||
if o.Timestamp.IsSet() {
|
||||
toSerialize["timestamp"] = o.Timestamp.Get()
|
||||
}
|
||||
if !IsNil(o.Children) {
|
||||
toSerialize["children"] = o.Children
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
func (o *KnowledgeNode) UnmarshalJSON(data []byte) (err error) {
|
||||
// This validates that all required properties are included in the JSON object
|
||||
// by unmarshalling the object into a generic map with string keys and checking
|
||||
// that every required field exists as a key in the generic map.
|
||||
requiredProperties := []string{
|
||||
"id",
|
||||
"kind",
|
||||
"name",
|
||||
}
|
||||
|
||||
allProperties := make(map[string]interface{})
|
||||
|
||||
err = json.Unmarshal(data, &allProperties)
|
||||
|
||||
if err != nil {
|
||||
return err;
|
||||
}
|
||||
|
||||
for _, requiredProperty := range(requiredProperties) {
|
||||
if _, exists := allProperties[requiredProperty]; !exists {
|
||||
return fmt.Errorf("no value given for required property %v", requiredProperty)
|
||||
}
|
||||
}
|
||||
|
||||
varKnowledgeNode := _KnowledgeNode{}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
err = decoder.Decode(&varKnowledgeNode)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*o = KnowledgeNode(varKnowledgeNode)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type NullableKnowledgeNode struct {
|
||||
value *KnowledgeNode
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableKnowledgeNode) Get() *KnowledgeNode {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgeNode) Set(val *KnowledgeNode) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableKnowledgeNode) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgeNode) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableKnowledgeNode(val *KnowledgeNode) *NullableKnowledgeNode {
|
||||
return &NullableKnowledgeNode{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableKnowledgeNode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgeNode) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.8.3
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// checks if the KnowledgePageBundleFile type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &KnowledgePageBundleFile{}
|
||||
|
||||
// KnowledgePageBundleFile One file in a portable OKF bundle.
|
||||
type KnowledgePageBundleFile struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type _KnowledgePageBundleFile KnowledgePageBundleFile
|
||||
|
||||
// NewKnowledgePageBundleFile instantiates a new KnowledgePageBundleFile object
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewKnowledgePageBundleFile(path string, content string) *KnowledgePageBundleFile {
|
||||
this := KnowledgePageBundleFile{}
|
||||
this.Path = path
|
||||
this.Content = content
|
||||
return &this
|
||||
}
|
||||
|
||||
// NewKnowledgePageBundleFileWithDefaults instantiates a new KnowledgePageBundleFile object
|
||||
// This constructor will only assign default values to properties that have it defined,
|
||||
// but it doesn't guarantee that properties required by API are set
|
||||
func NewKnowledgePageBundleFileWithDefaults() *KnowledgePageBundleFile {
|
||||
this := KnowledgePageBundleFile{}
|
||||
return &this
|
||||
}
|
||||
|
||||
// GetPath returns the Path field value
|
||||
func (o *KnowledgePageBundleFile) GetPath() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Path
|
||||
}
|
||||
|
||||
// GetPathOk returns a tuple with the Path field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgePageBundleFile) GetPathOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Path, true
|
||||
}
|
||||
|
||||
// SetPath sets field value
|
||||
func (o *KnowledgePageBundleFile) SetPath(v string) {
|
||||
o.Path = v
|
||||
}
|
||||
|
||||
// GetContent returns the Content field value
|
||||
func (o *KnowledgePageBundleFile) GetContent() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Content
|
||||
}
|
||||
|
||||
// GetContentOk returns a tuple with the Content field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgePageBundleFile) GetContentOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Content, true
|
||||
}
|
||||
|
||||
// SetContent sets field value
|
||||
func (o *KnowledgePageBundleFile) SetContent(v string) {
|
||||
o.Content = v
|
||||
}
|
||||
|
||||
func (o KnowledgePageBundleFile) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return json.Marshal(toSerialize)
|
||||
}
|
||||
|
||||
func (o KnowledgePageBundleFile) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
toSerialize["path"] = o.Path
|
||||
toSerialize["content"] = o.Content
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
func (o *KnowledgePageBundleFile) UnmarshalJSON(data []byte) (err error) {
|
||||
// This validates that all required properties are included in the JSON object
|
||||
// by unmarshalling the object into a generic map with string keys and checking
|
||||
// that every required field exists as a key in the generic map.
|
||||
requiredProperties := []string{
|
||||
"path",
|
||||
"content",
|
||||
}
|
||||
|
||||
allProperties := make(map[string]interface{})
|
||||
|
||||
err = json.Unmarshal(data, &allProperties)
|
||||
|
||||
if err != nil {
|
||||
return err;
|
||||
}
|
||||
|
||||
for _, requiredProperty := range(requiredProperties) {
|
||||
if _, exists := allProperties[requiredProperty]; !exists {
|
||||
return fmt.Errorf("no value given for required property %v", requiredProperty)
|
||||
}
|
||||
}
|
||||
|
||||
varKnowledgePageBundleFile := _KnowledgePageBundleFile{}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
err = decoder.Decode(&varKnowledgePageBundleFile)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*o = KnowledgePageBundleFile(varKnowledgePageBundleFile)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type NullableKnowledgePageBundleFile struct {
|
||||
value *KnowledgePageBundleFile
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableKnowledgePageBundleFile) Get() *KnowledgePageBundleFile {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgePageBundleFile) Set(val *KnowledgePageBundleFile) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableKnowledgePageBundleFile) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgePageBundleFile) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableKnowledgePageBundleFile(val *KnowledgePageBundleFile) *NullableKnowledgePageBundleFile {
|
||||
return &NullableKnowledgePageBundleFile{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableKnowledgePageBundleFile) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgePageBundleFile) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.8.3
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// checks if the KnowledgePageBundleResponse type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &KnowledgePageBundleResponse{}
|
||||
|
||||
// KnowledgePageBundleResponse A portable OKF bundle — a flat set of markdown files (index + pages + logs).
|
||||
type KnowledgePageBundleResponse struct {
|
||||
Files []KnowledgePageBundleFile `json:"files"`
|
||||
}
|
||||
|
||||
type _KnowledgePageBundleResponse KnowledgePageBundleResponse
|
||||
|
||||
// NewKnowledgePageBundleResponse instantiates a new KnowledgePageBundleResponse object
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewKnowledgePageBundleResponse(files []KnowledgePageBundleFile) *KnowledgePageBundleResponse {
|
||||
this := KnowledgePageBundleResponse{}
|
||||
this.Files = files
|
||||
return &this
|
||||
}
|
||||
|
||||
// NewKnowledgePageBundleResponseWithDefaults instantiates a new KnowledgePageBundleResponse object
|
||||
// This constructor will only assign default values to properties that have it defined,
|
||||
// but it doesn't guarantee that properties required by API are set
|
||||
func NewKnowledgePageBundleResponseWithDefaults() *KnowledgePageBundleResponse {
|
||||
this := KnowledgePageBundleResponse{}
|
||||
return &this
|
||||
}
|
||||
|
||||
// GetFiles returns the Files field value
|
||||
func (o *KnowledgePageBundleResponse) GetFiles() []KnowledgePageBundleFile {
|
||||
if o == nil {
|
||||
var ret []KnowledgePageBundleFile
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Files
|
||||
}
|
||||
|
||||
// GetFilesOk returns a tuple with the Files field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgePageBundleResponse) GetFilesOk() ([]KnowledgePageBundleFile, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.Files, true
|
||||
}
|
||||
|
||||
// SetFiles sets field value
|
||||
func (o *KnowledgePageBundleResponse) SetFiles(v []KnowledgePageBundleFile) {
|
||||
o.Files = v
|
||||
}
|
||||
|
||||
func (o KnowledgePageBundleResponse) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return json.Marshal(toSerialize)
|
||||
}
|
||||
|
||||
func (o KnowledgePageBundleResponse) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
toSerialize["files"] = o.Files
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
func (o *KnowledgePageBundleResponse) UnmarshalJSON(data []byte) (err error) {
|
||||
// This validates that all required properties are included in the JSON object
|
||||
// by unmarshalling the object into a generic map with string keys and checking
|
||||
// that every required field exists as a key in the generic map.
|
||||
requiredProperties := []string{
|
||||
"files",
|
||||
}
|
||||
|
||||
allProperties := make(map[string]interface{})
|
||||
|
||||
err = json.Unmarshal(data, &allProperties)
|
||||
|
||||
if err != nil {
|
||||
return err;
|
||||
}
|
||||
|
||||
for _, requiredProperty := range(requiredProperties) {
|
||||
if _, exists := allProperties[requiredProperty]; !exists {
|
||||
return fmt.Errorf("no value given for required property %v", requiredProperty)
|
||||
}
|
||||
}
|
||||
|
||||
varKnowledgePageBundleResponse := _KnowledgePageBundleResponse{}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
err = decoder.Decode(&varKnowledgePageBundleResponse)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*o = KnowledgePageBundleResponse(varKnowledgePageBundleResponse)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type NullableKnowledgePageBundleResponse struct {
|
||||
value *KnowledgePageBundleResponse
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableKnowledgePageBundleResponse) Get() *KnowledgePageBundleResponse {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgePageBundleResponse) Set(val *KnowledgePageBundleResponse) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableKnowledgePageBundleResponse) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgePageBundleResponse) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableKnowledgePageBundleResponse(val *KnowledgePageBundleResponse) *NullableKnowledgePageBundleResponse {
|
||||
return &NullableKnowledgePageBundleResponse{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableKnowledgePageBundleResponse) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgePageBundleResponse) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.8.3
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// checks if the KnowledgePageGraphResponse type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &KnowledgePageGraphResponse{}
|
||||
|
||||
// KnowledgePageGraphResponse Constellation graph of knowledge pages linked by shared tags.
|
||||
type KnowledgePageGraphResponse struct {
|
||||
Nodes []map[string]interface{} `json:"nodes"`
|
||||
Edges []map[string]interface{} `json:"edges"`
|
||||
TotalPages int32 `json:"total_pages"`
|
||||
TotalEdges int32 `json:"total_edges"`
|
||||
}
|
||||
|
||||
type _KnowledgePageGraphResponse KnowledgePageGraphResponse
|
||||
|
||||
// NewKnowledgePageGraphResponse instantiates a new KnowledgePageGraphResponse object
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewKnowledgePageGraphResponse(nodes []map[string]interface{}, edges []map[string]interface{}, totalPages int32, totalEdges int32) *KnowledgePageGraphResponse {
|
||||
this := KnowledgePageGraphResponse{}
|
||||
this.Nodes = nodes
|
||||
this.Edges = edges
|
||||
this.TotalPages = totalPages
|
||||
this.TotalEdges = totalEdges
|
||||
return &this
|
||||
}
|
||||
|
||||
// NewKnowledgePageGraphResponseWithDefaults instantiates a new KnowledgePageGraphResponse object
|
||||
// This constructor will only assign default values to properties that have it defined,
|
||||
// but it doesn't guarantee that properties required by API are set
|
||||
func NewKnowledgePageGraphResponseWithDefaults() *KnowledgePageGraphResponse {
|
||||
this := KnowledgePageGraphResponse{}
|
||||
return &this
|
||||
}
|
||||
|
||||
// GetNodes returns the Nodes field value
|
||||
func (o *KnowledgePageGraphResponse) GetNodes() []map[string]interface{} {
|
||||
if o == nil {
|
||||
var ret []map[string]interface{}
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Nodes
|
||||
}
|
||||
|
||||
// GetNodesOk returns a tuple with the Nodes field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgePageGraphResponse) GetNodesOk() ([]map[string]interface{}, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.Nodes, true
|
||||
}
|
||||
|
||||
// SetNodes sets field value
|
||||
func (o *KnowledgePageGraphResponse) SetNodes(v []map[string]interface{}) {
|
||||
o.Nodes = v
|
||||
}
|
||||
|
||||
// GetEdges returns the Edges field value
|
||||
func (o *KnowledgePageGraphResponse) GetEdges() []map[string]interface{} {
|
||||
if o == nil {
|
||||
var ret []map[string]interface{}
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Edges
|
||||
}
|
||||
|
||||
// GetEdgesOk returns a tuple with the Edges field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgePageGraphResponse) GetEdgesOk() ([]map[string]interface{}, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.Edges, true
|
||||
}
|
||||
|
||||
// SetEdges sets field value
|
||||
func (o *KnowledgePageGraphResponse) SetEdges(v []map[string]interface{}) {
|
||||
o.Edges = v
|
||||
}
|
||||
|
||||
// GetTotalPages returns the TotalPages field value
|
||||
func (o *KnowledgePageGraphResponse) GetTotalPages() int32 {
|
||||
if o == nil {
|
||||
var ret int32
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.TotalPages
|
||||
}
|
||||
|
||||
// GetTotalPagesOk returns a tuple with the TotalPages field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgePageGraphResponse) GetTotalPagesOk() (*int32, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.TotalPages, true
|
||||
}
|
||||
|
||||
// SetTotalPages sets field value
|
||||
func (o *KnowledgePageGraphResponse) SetTotalPages(v int32) {
|
||||
o.TotalPages = v
|
||||
}
|
||||
|
||||
// GetTotalEdges returns the TotalEdges field value
|
||||
func (o *KnowledgePageGraphResponse) GetTotalEdges() int32 {
|
||||
if o == nil {
|
||||
var ret int32
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.TotalEdges
|
||||
}
|
||||
|
||||
// GetTotalEdgesOk returns a tuple with the TotalEdges field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgePageGraphResponse) GetTotalEdgesOk() (*int32, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.TotalEdges, true
|
||||
}
|
||||
|
||||
// SetTotalEdges sets field value
|
||||
func (o *KnowledgePageGraphResponse) SetTotalEdges(v int32) {
|
||||
o.TotalEdges = v
|
||||
}
|
||||
|
||||
func (o KnowledgePageGraphResponse) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return json.Marshal(toSerialize)
|
||||
}
|
||||
|
||||
func (o KnowledgePageGraphResponse) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
toSerialize["nodes"] = o.Nodes
|
||||
toSerialize["edges"] = o.Edges
|
||||
toSerialize["total_pages"] = o.TotalPages
|
||||
toSerialize["total_edges"] = o.TotalEdges
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
func (o *KnowledgePageGraphResponse) UnmarshalJSON(data []byte) (err error) {
|
||||
// This validates that all required properties are included in the JSON object
|
||||
// by unmarshalling the object into a generic map with string keys and checking
|
||||
// that every required field exists as a key in the generic map.
|
||||
requiredProperties := []string{
|
||||
"nodes",
|
||||
"edges",
|
||||
"total_pages",
|
||||
"total_edges",
|
||||
}
|
||||
|
||||
allProperties := make(map[string]interface{})
|
||||
|
||||
err = json.Unmarshal(data, &allProperties)
|
||||
|
||||
if err != nil {
|
||||
return err;
|
||||
}
|
||||
|
||||
for _, requiredProperty := range(requiredProperties) {
|
||||
if _, exists := allProperties[requiredProperty]; !exists {
|
||||
return fmt.Errorf("no value given for required property %v", requiredProperty)
|
||||
}
|
||||
}
|
||||
|
||||
varKnowledgePageGraphResponse := _KnowledgePageGraphResponse{}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
err = decoder.Decode(&varKnowledgePageGraphResponse)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*o = KnowledgePageGraphResponse(varKnowledgePageGraphResponse)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type NullableKnowledgePageGraphResponse struct {
|
||||
value *KnowledgePageGraphResponse
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableKnowledgePageGraphResponse) Get() *KnowledgePageGraphResponse {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgePageGraphResponse) Set(val *KnowledgePageGraphResponse) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableKnowledgePageGraphResponse) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgePageGraphResponse) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableKnowledgePageGraphResponse(val *KnowledgePageGraphResponse) *NullableKnowledgePageGraphResponse {
|
||||
return &NullableKnowledgePageGraphResponse{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableKnowledgePageGraphResponse) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgePageGraphResponse) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.8.3
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// checks if the KnowledgePageResponse type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &KnowledgePageResponse{}
|
||||
|
||||
// KnowledgePageResponse A knowledge page rendered as an OKF document.
|
||||
type KnowledgePageResponse struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// OKF document type — from a `type:<x>` tag, else 'knowledge-page'.
|
||||
Type string `json:"type"`
|
||||
Description NullableString `json:"description,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Timestamp NullableString `json:"timestamp,omitempty"`
|
||||
Body NullableString `json:"body,omitempty"`
|
||||
// The full OKF document: YAML frontmatter + markdown body.
|
||||
Markdown string `json:"markdown"`
|
||||
}
|
||||
|
||||
type _KnowledgePageResponse KnowledgePageResponse
|
||||
|
||||
// NewKnowledgePageResponse instantiates a new KnowledgePageResponse object
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewKnowledgePageResponse(id string, name string, type_ string, markdown string) *KnowledgePageResponse {
|
||||
this := KnowledgePageResponse{}
|
||||
this.Id = id
|
||||
this.Name = name
|
||||
this.Type = type_
|
||||
this.Markdown = markdown
|
||||
return &this
|
||||
}
|
||||
|
||||
// NewKnowledgePageResponseWithDefaults instantiates a new KnowledgePageResponse object
|
||||
// This constructor will only assign default values to properties that have it defined,
|
||||
// but it doesn't guarantee that properties required by API are set
|
||||
func NewKnowledgePageResponseWithDefaults() *KnowledgePageResponse {
|
||||
this := KnowledgePageResponse{}
|
||||
return &this
|
||||
}
|
||||
|
||||
// GetId returns the Id field value
|
||||
func (o *KnowledgePageResponse) GetId() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Id
|
||||
}
|
||||
|
||||
// GetIdOk returns a tuple with the Id field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgePageResponse) GetIdOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Id, true
|
||||
}
|
||||
|
||||
// SetId sets field value
|
||||
func (o *KnowledgePageResponse) SetId(v string) {
|
||||
o.Id = v
|
||||
}
|
||||
|
||||
// GetName returns the Name field value
|
||||
func (o *KnowledgePageResponse) GetName() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Name
|
||||
}
|
||||
|
||||
// GetNameOk returns a tuple with the Name field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgePageResponse) GetNameOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Name, true
|
||||
}
|
||||
|
||||
// SetName sets field value
|
||||
func (o *KnowledgePageResponse) SetName(v string) {
|
||||
o.Name = v
|
||||
}
|
||||
|
||||
// GetType returns the Type field value
|
||||
func (o *KnowledgePageResponse) GetType() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Type
|
||||
}
|
||||
|
||||
// GetTypeOk returns a tuple with the Type field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgePageResponse) GetTypeOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Type, true
|
||||
}
|
||||
|
||||
// SetType sets field value
|
||||
func (o *KnowledgePageResponse) SetType(v string) {
|
||||
o.Type = v
|
||||
}
|
||||
|
||||
// GetDescription returns the Description field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *KnowledgePageResponse) GetDescription() string {
|
||||
if o == nil || IsNil(o.Description.Get()) {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
return *o.Description.Get()
|
||||
}
|
||||
|
||||
// GetDescriptionOk returns a tuple with the Description 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 *KnowledgePageResponse) GetDescriptionOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.Description.Get(), o.Description.IsSet()
|
||||
}
|
||||
|
||||
// HasDescription returns a boolean if a field has been set.
|
||||
func (o *KnowledgePageResponse) HasDescription() bool {
|
||||
if o != nil && o.Description.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetDescription gets a reference to the given NullableString and assigns it to the Description field.
|
||||
func (o *KnowledgePageResponse) SetDescription(v string) {
|
||||
o.Description.Set(&v)
|
||||
}
|
||||
// SetDescriptionNil sets the value for Description to be an explicit nil
|
||||
func (o *KnowledgePageResponse) SetDescriptionNil() {
|
||||
o.Description.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetDescription ensures that no value is present for Description, not even an explicit nil
|
||||
func (o *KnowledgePageResponse) UnsetDescription() {
|
||||
o.Description.Unset()
|
||||
}
|
||||
|
||||
// GetTags returns the Tags field value if set, zero value otherwise.
|
||||
func (o *KnowledgePageResponse) GetTags() []string {
|
||||
if o == nil || IsNil(o.Tags) {
|
||||
var ret []string
|
||||
return ret
|
||||
}
|
||||
return o.Tags
|
||||
}
|
||||
|
||||
// GetTagsOk returns a tuple with the Tags field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgePageResponse) GetTagsOk() ([]string, bool) {
|
||||
if o == nil || IsNil(o.Tags) {
|
||||
return nil, false
|
||||
}
|
||||
return o.Tags, true
|
||||
}
|
||||
|
||||
// HasTags returns a boolean if a field has been set.
|
||||
func (o *KnowledgePageResponse) HasTags() bool {
|
||||
if o != nil && !IsNil(o.Tags) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetTags gets a reference to the given []string and assigns it to the Tags field.
|
||||
func (o *KnowledgePageResponse) SetTags(v []string) {
|
||||
o.Tags = v
|
||||
}
|
||||
|
||||
// GetTimestamp returns the Timestamp field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *KnowledgePageResponse) GetTimestamp() string {
|
||||
if o == nil || IsNil(o.Timestamp.Get()) {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
return *o.Timestamp.Get()
|
||||
}
|
||||
|
||||
// GetTimestampOk returns a tuple with the Timestamp 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 *KnowledgePageResponse) GetTimestampOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.Timestamp.Get(), o.Timestamp.IsSet()
|
||||
}
|
||||
|
||||
// HasTimestamp returns a boolean if a field has been set.
|
||||
func (o *KnowledgePageResponse) HasTimestamp() bool {
|
||||
if o != nil && o.Timestamp.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetTimestamp gets a reference to the given NullableString and assigns it to the Timestamp field.
|
||||
func (o *KnowledgePageResponse) SetTimestamp(v string) {
|
||||
o.Timestamp.Set(&v)
|
||||
}
|
||||
// SetTimestampNil sets the value for Timestamp to be an explicit nil
|
||||
func (o *KnowledgePageResponse) SetTimestampNil() {
|
||||
o.Timestamp.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetTimestamp ensures that no value is present for Timestamp, not even an explicit nil
|
||||
func (o *KnowledgePageResponse) UnsetTimestamp() {
|
||||
o.Timestamp.Unset()
|
||||
}
|
||||
|
||||
// GetBody returns the Body field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *KnowledgePageResponse) GetBody() string {
|
||||
if o == nil || IsNil(o.Body.Get()) {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
return *o.Body.Get()
|
||||
}
|
||||
|
||||
// GetBodyOk returns a tuple with the Body 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 *KnowledgePageResponse) GetBodyOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.Body.Get(), o.Body.IsSet()
|
||||
}
|
||||
|
||||
// HasBody returns a boolean if a field has been set.
|
||||
func (o *KnowledgePageResponse) HasBody() bool {
|
||||
if o != nil && o.Body.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetBody gets a reference to the given NullableString and assigns it to the Body field.
|
||||
func (o *KnowledgePageResponse) SetBody(v string) {
|
||||
o.Body.Set(&v)
|
||||
}
|
||||
// SetBodyNil sets the value for Body to be an explicit nil
|
||||
func (o *KnowledgePageResponse) SetBodyNil() {
|
||||
o.Body.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetBody ensures that no value is present for Body, not even an explicit nil
|
||||
func (o *KnowledgePageResponse) UnsetBody() {
|
||||
o.Body.Unset()
|
||||
}
|
||||
|
||||
// GetMarkdown returns the Markdown field value
|
||||
func (o *KnowledgePageResponse) GetMarkdown() string {
|
||||
if o == nil {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Markdown
|
||||
}
|
||||
|
||||
// GetMarkdownOk returns a tuple with the Markdown field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgePageResponse) GetMarkdownOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.Markdown, true
|
||||
}
|
||||
|
||||
// SetMarkdown sets field value
|
||||
func (o *KnowledgePageResponse) SetMarkdown(v string) {
|
||||
o.Markdown = v
|
||||
}
|
||||
|
||||
func (o KnowledgePageResponse) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return json.Marshal(toSerialize)
|
||||
}
|
||||
|
||||
func (o KnowledgePageResponse) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
toSerialize["id"] = o.Id
|
||||
toSerialize["name"] = o.Name
|
||||
toSerialize["type"] = o.Type
|
||||
if o.Description.IsSet() {
|
||||
toSerialize["description"] = o.Description.Get()
|
||||
}
|
||||
if !IsNil(o.Tags) {
|
||||
toSerialize["tags"] = o.Tags
|
||||
}
|
||||
if o.Timestamp.IsSet() {
|
||||
toSerialize["timestamp"] = o.Timestamp.Get()
|
||||
}
|
||||
if o.Body.IsSet() {
|
||||
toSerialize["body"] = o.Body.Get()
|
||||
}
|
||||
toSerialize["markdown"] = o.Markdown
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
func (o *KnowledgePageResponse) UnmarshalJSON(data []byte) (err error) {
|
||||
// This validates that all required properties are included in the JSON object
|
||||
// by unmarshalling the object into a generic map with string keys and checking
|
||||
// that every required field exists as a key in the generic map.
|
||||
requiredProperties := []string{
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"markdown",
|
||||
}
|
||||
|
||||
allProperties := make(map[string]interface{})
|
||||
|
||||
err = json.Unmarshal(data, &allProperties)
|
||||
|
||||
if err != nil {
|
||||
return err;
|
||||
}
|
||||
|
||||
for _, requiredProperty := range(requiredProperties) {
|
||||
if _, exists := allProperties[requiredProperty]; !exists {
|
||||
return fmt.Errorf("no value given for required property %v", requiredProperty)
|
||||
}
|
||||
}
|
||||
|
||||
varKnowledgePageResponse := _KnowledgePageResponse{}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
err = decoder.Decode(&varKnowledgePageResponse)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*o = KnowledgePageResponse(varKnowledgePageResponse)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type NullableKnowledgePageResponse struct {
|
||||
value *KnowledgePageResponse
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableKnowledgePageResponse) Get() *KnowledgePageResponse {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgePageResponse) Set(val *KnowledgePageResponse) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableKnowledgePageResponse) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgePageResponse) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableKnowledgePageResponse(val *KnowledgePageResponse) *NullableKnowledgePageResponse {
|
||||
return &NullableKnowledgePageResponse{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableKnowledgePageResponse) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgePageResponse) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.8.3
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// checks if the KnowledgeTreeResponse type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &KnowledgeTreeResponse{}
|
||||
|
||||
// KnowledgeTreeResponse The knowledge base as a nested folder/page tree.
|
||||
type KnowledgeTreeResponse struct {
|
||||
Roots []KnowledgeNode `json:"roots"`
|
||||
}
|
||||
|
||||
type _KnowledgeTreeResponse KnowledgeTreeResponse
|
||||
|
||||
// NewKnowledgeTreeResponse instantiates a new KnowledgeTreeResponse object
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewKnowledgeTreeResponse(roots []KnowledgeNode) *KnowledgeTreeResponse {
|
||||
this := KnowledgeTreeResponse{}
|
||||
this.Roots = roots
|
||||
return &this
|
||||
}
|
||||
|
||||
// NewKnowledgeTreeResponseWithDefaults instantiates a new KnowledgeTreeResponse object
|
||||
// This constructor will only assign default values to properties that have it defined,
|
||||
// but it doesn't guarantee that properties required by API are set
|
||||
func NewKnowledgeTreeResponseWithDefaults() *KnowledgeTreeResponse {
|
||||
this := KnowledgeTreeResponse{}
|
||||
return &this
|
||||
}
|
||||
|
||||
// GetRoots returns the Roots field value
|
||||
func (o *KnowledgeTreeResponse) GetRoots() []KnowledgeNode {
|
||||
if o == nil {
|
||||
var ret []KnowledgeNode
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.Roots
|
||||
}
|
||||
|
||||
// GetRootsOk returns a tuple with the Roots field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *KnowledgeTreeResponse) GetRootsOk() ([]KnowledgeNode, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.Roots, true
|
||||
}
|
||||
|
||||
// SetRoots sets field value
|
||||
func (o *KnowledgeTreeResponse) SetRoots(v []KnowledgeNode) {
|
||||
o.Roots = v
|
||||
}
|
||||
|
||||
func (o KnowledgeTreeResponse) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return json.Marshal(toSerialize)
|
||||
}
|
||||
|
||||
func (o KnowledgeTreeResponse) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
toSerialize["roots"] = o.Roots
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
func (o *KnowledgeTreeResponse) UnmarshalJSON(data []byte) (err error) {
|
||||
// This validates that all required properties are included in the JSON object
|
||||
// by unmarshalling the object into a generic map with string keys and checking
|
||||
// that every required field exists as a key in the generic map.
|
||||
requiredProperties := []string{
|
||||
"roots",
|
||||
}
|
||||
|
||||
allProperties := make(map[string]interface{})
|
||||
|
||||
err = json.Unmarshal(data, &allProperties)
|
||||
|
||||
if err != nil {
|
||||
return err;
|
||||
}
|
||||
|
||||
for _, requiredProperty := range(requiredProperties) {
|
||||
if _, exists := allProperties[requiredProperty]; !exists {
|
||||
return fmt.Errorf("no value given for required property %v", requiredProperty)
|
||||
}
|
||||
}
|
||||
|
||||
varKnowledgeTreeResponse := _KnowledgeTreeResponse{}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
err = decoder.Decode(&varKnowledgeTreeResponse)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*o = KnowledgeTreeResponse(varKnowledgeTreeResponse)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type NullableKnowledgeTreeResponse struct {
|
||||
value *KnowledgeTreeResponse
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableKnowledgeTreeResponse) Get() *KnowledgeTreeResponse {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgeTreeResponse) Set(val *KnowledgeTreeResponse) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableKnowledgeTreeResponse) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgeTreeResponse) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableKnowledgeTreeResponse(val *KnowledgeTreeResponse) *NullableKnowledgeTreeResponse {
|
||||
return &NullableKnowledgeTreeResponse{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableKnowledgeTreeResponse) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableKnowledgeTreeResponse) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.8.3
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// checks if the UpdateNodeRequest type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &UpdateNodeRequest{}
|
||||
|
||||
// UpdateNodeRequest Rename and/or move a node. Each field applies only when present.
|
||||
type UpdateNodeRequest struct {
|
||||
Name NullableString `json:"name,omitempty"`
|
||||
ParentId NullableString `json:"parent_id,omitempty"`
|
||||
}
|
||||
|
||||
// NewUpdateNodeRequest instantiates a new UpdateNodeRequest object
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewUpdateNodeRequest() *UpdateNodeRequest {
|
||||
this := UpdateNodeRequest{}
|
||||
return &this
|
||||
}
|
||||
|
||||
// NewUpdateNodeRequestWithDefaults instantiates a new UpdateNodeRequest object
|
||||
// This constructor will only assign default values to properties that have it defined,
|
||||
// but it doesn't guarantee that properties required by API are set
|
||||
func NewUpdateNodeRequestWithDefaults() *UpdateNodeRequest {
|
||||
this := UpdateNodeRequest{}
|
||||
return &this
|
||||
}
|
||||
|
||||
// GetName returns the Name field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *UpdateNodeRequest) GetName() string {
|
||||
if o == nil || IsNil(o.Name.Get()) {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
return *o.Name.Get()
|
||||
}
|
||||
|
||||
// GetNameOk returns a tuple with the Name 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 *UpdateNodeRequest) GetNameOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.Name.Get(), o.Name.IsSet()
|
||||
}
|
||||
|
||||
// HasName returns a boolean if a field has been set.
|
||||
func (o *UpdateNodeRequest) HasName() bool {
|
||||
if o != nil && o.Name.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetName gets a reference to the given NullableString and assigns it to the Name field.
|
||||
func (o *UpdateNodeRequest) SetName(v string) {
|
||||
o.Name.Set(&v)
|
||||
}
|
||||
// SetNameNil sets the value for Name to be an explicit nil
|
||||
func (o *UpdateNodeRequest) SetNameNil() {
|
||||
o.Name.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetName ensures that no value is present for Name, not even an explicit nil
|
||||
func (o *UpdateNodeRequest) UnsetName() {
|
||||
o.Name.Unset()
|
||||
}
|
||||
|
||||
// GetParentId returns the ParentId field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *UpdateNodeRequest) GetParentId() string {
|
||||
if o == nil || IsNil(o.ParentId.Get()) {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
return *o.ParentId.Get()
|
||||
}
|
||||
|
||||
// GetParentIdOk returns a tuple with the ParentId 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 *UpdateNodeRequest) GetParentIdOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.ParentId.Get(), o.ParentId.IsSet()
|
||||
}
|
||||
|
||||
// HasParentId returns a boolean if a field has been set.
|
||||
func (o *UpdateNodeRequest) HasParentId() bool {
|
||||
if o != nil && o.ParentId.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetParentId gets a reference to the given NullableString and assigns it to the ParentId field.
|
||||
func (o *UpdateNodeRequest) SetParentId(v string) {
|
||||
o.ParentId.Set(&v)
|
||||
}
|
||||
// SetParentIdNil sets the value for ParentId to be an explicit nil
|
||||
func (o *UpdateNodeRequest) SetParentIdNil() {
|
||||
o.ParentId.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetParentId ensures that no value is present for ParentId, not even an explicit nil
|
||||
func (o *UpdateNodeRequest) UnsetParentId() {
|
||||
o.ParentId.Unset()
|
||||
}
|
||||
|
||||
func (o UpdateNodeRequest) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return json.Marshal(toSerialize)
|
||||
}
|
||||
|
||||
func (o UpdateNodeRequest) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
if o.Name.IsSet() {
|
||||
toSerialize["name"] = o.Name.Get()
|
||||
}
|
||||
if o.ParentId.IsSet() {
|
||||
toSerialize["parent_id"] = o.ParentId.Get()
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
type NullableUpdateNodeRequest struct {
|
||||
value *UpdateNodeRequest
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableUpdateNodeRequest) Get() *UpdateNodeRequest {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableUpdateNodeRequest) Set(val *UpdateNodeRequest) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableUpdateNodeRequest) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableUpdateNodeRequest) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableUpdateNodeRequest(val *UpdateNodeRequest) *NullableUpdateNodeRequest {
|
||||
return &NullableUpdateNodeRequest{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableUpdateNodeRequest) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableUpdateNodeRequest) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ hindsight_client_api/api/document_transfer_api.py
|
||||
hindsight_client_api/api/documents_api.py
|
||||
hindsight_client_api/api/entities_api.py
|
||||
hindsight_client_api/api/files_api.py
|
||||
hindsight_client_api/api/knowledge_base_api.py
|
||||
hindsight_client_api/api/llm_traces_api.py
|
||||
hindsight_client_api/api/memory_api.py
|
||||
hindsight_client_api/api/mental_models_api.py
|
||||
@@ -51,11 +50,8 @@ hindsight_client_api/models/consolidation_request.py
|
||||
hindsight_client_api/models/consolidation_response.py
|
||||
hindsight_client_api/models/create_bank_request.py
|
||||
hindsight_client_api/models/create_directive_request.py
|
||||
hindsight_client_api/models/create_folder_request.py
|
||||
hindsight_client_api/models/create_knowledge_page_response.py
|
||||
hindsight_client_api/models/create_mental_model_request.py
|
||||
hindsight_client_api/models/create_mental_model_response.py
|
||||
hindsight_client_api/models/create_page_request.py
|
||||
hindsight_client_api/models/create_webhook_request.py
|
||||
hindsight_client_api/models/delete_document_response.py
|
||||
hindsight_client_api/models/delete_response.py
|
||||
@@ -80,12 +76,6 @@ hindsight_client_api/models/file_retain_response.py
|
||||
hindsight_client_api/models/graph_data_response.py
|
||||
hindsight_client_api/models/http_validation_error.py
|
||||
hindsight_client_api/models/include_options.py
|
||||
hindsight_client_api/models/knowledge_node.py
|
||||
hindsight_client_api/models/knowledge_page_bundle_file.py
|
||||
hindsight_client_api/models/knowledge_page_bundle_response.py
|
||||
hindsight_client_api/models/knowledge_page_graph_response.py
|
||||
hindsight_client_api/models/knowledge_page_response.py
|
||||
hindsight_client_api/models/knowledge_tree_response.py
|
||||
hindsight_client_api/models/list_chunks_response.py
|
||||
hindsight_client_api/models/list_documents_response.py
|
||||
hindsight_client_api/models/list_memory_units_response.py
|
||||
@@ -152,7 +142,6 @@ hindsight_client_api/models/update_document_request.py
|
||||
hindsight_client_api/models/update_document_response.py
|
||||
hindsight_client_api/models/update_memory_request.py
|
||||
hindsight_client_api/models/update_mental_model_request.py
|
||||
hindsight_client_api/models/update_node_request.py
|
||||
hindsight_client_api/models/update_webhook_request.py
|
||||
hindsight_client_api/models/validation_error.py
|
||||
hindsight_client_api/models/validation_error_loc_inner.py
|
||||
|
||||
@@ -592,7 +592,7 @@ class Hindsight:
|
||||
disposition_empathy: Deprecated. Use update_bank_config(disposition_empathy=...) instead.
|
||||
disposition: Deprecated. Use update_bank_config(disposition_skepticism=...) instead.
|
||||
retain_mission: Steers what gets extracted during retain(). Injected alongside built-in rules.
|
||||
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.
|
||||
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.
|
||||
retain_custom_instructions: Custom extraction prompt (only active when mode is 'custom').
|
||||
retain_chunk_size: Target maximum characters for each content chunk during retain.
|
||||
retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation
|
||||
@@ -731,7 +731,7 @@ class Hindsight:
|
||||
disposition_empathy: Deprecated. Use update_bank_config(disposition_empathy=...) instead.
|
||||
disposition: Deprecated. Use update_bank_config(disposition_skepticism=...) instead.
|
||||
retain_mission: Steers what gets extracted during retain(). Injected alongside built-in rules.
|
||||
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.
|
||||
retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.
|
||||
retain_custom_instructions: Custom extraction prompt (only active when mode is 'custom').
|
||||
retain_chunk_size: Target maximum characters for each content chunk during retain.
|
||||
retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation
|
||||
|
||||
@@ -25,7 +25,6 @@ from hindsight_client_api.api.document_transfer_api import DocumentTransferApi
|
||||
from hindsight_client_api.api.documents_api import DocumentsApi
|
||||
from hindsight_client_api.api.entities_api import EntitiesApi
|
||||
from hindsight_client_api.api.files_api import FilesApi
|
||||
from hindsight_client_api.api.knowledge_base_api import KnowledgeBaseApi
|
||||
from hindsight_client_api.api.llm_traces_api import LLMTracesApi
|
||||
from hindsight_client_api.api.memory_api import MemoryApi
|
||||
from hindsight_client_api.api.mental_models_api import MentalModelsApi
|
||||
@@ -75,11 +74,8 @@ from hindsight_client_api.models.consolidation_request import ConsolidationReque
|
||||
from hindsight_client_api.models.consolidation_response import ConsolidationResponse
|
||||
from hindsight_client_api.models.create_bank_request import CreateBankRequest
|
||||
from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest
|
||||
from hindsight_client_api.models.create_folder_request import CreateFolderRequest
|
||||
from hindsight_client_api.models.create_knowledge_page_response import CreateKnowledgePageResponse
|
||||
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
|
||||
from hindsight_client_api.models.create_mental_model_response import CreateMentalModelResponse
|
||||
from hindsight_client_api.models.create_page_request import CreatePageRequest
|
||||
from hindsight_client_api.models.create_webhook_request import CreateWebhookRequest
|
||||
from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse
|
||||
from hindsight_client_api.models.delete_response import DeleteResponse
|
||||
@@ -104,12 +100,6 @@ from hindsight_client_api.models.file_retain_response import FileRetainResponse
|
||||
from hindsight_client_api.models.graph_data_response import GraphDataResponse
|
||||
from hindsight_client_api.models.http_validation_error import HTTPValidationError
|
||||
from hindsight_client_api.models.include_options import IncludeOptions
|
||||
from hindsight_client_api.models.knowledge_node import KnowledgeNode
|
||||
from hindsight_client_api.models.knowledge_page_bundle_file import KnowledgePageBundleFile
|
||||
from hindsight_client_api.models.knowledge_page_bundle_response import KnowledgePageBundleResponse
|
||||
from hindsight_client_api.models.knowledge_page_graph_response import KnowledgePageGraphResponse
|
||||
from hindsight_client_api.models.knowledge_page_response import KnowledgePageResponse
|
||||
from hindsight_client_api.models.knowledge_tree_response import KnowledgeTreeResponse
|
||||
from hindsight_client_api.models.llm_request_entry import LLMRequestEntry
|
||||
from hindsight_client_api.models.llm_request_list_response import LLMRequestListResponse
|
||||
from hindsight_client_api.models.llm_request_stats_bucket import LLMRequestStatsBucket
|
||||
@@ -176,7 +166,6 @@ from hindsight_client_api.models.update_document_request import UpdateDocumentRe
|
||||
from hindsight_client_api.models.update_document_response import UpdateDocumentResponse
|
||||
from hindsight_client_api.models.update_memory_request import UpdateMemoryRequest
|
||||
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
|
||||
from hindsight_client_api.models.update_node_request import UpdateNodeRequest
|
||||
from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest
|
||||
from hindsight_client_api.models.validation_error import ValidationError
|
||||
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
|
||||
|
||||
@@ -9,7 +9,6 @@ from hindsight_client_api.api.document_transfer_api import DocumentTransferApi
|
||||
from hindsight_client_api.api.documents_api import DocumentsApi
|
||||
from hindsight_client_api.api.entities_api import EntitiesApi
|
||||
from hindsight_client_api.api.files_api import FilesApi
|
||||
from hindsight_client_api.api.knowledge_base_api import KnowledgeBaseApi
|
||||
from hindsight_client_api.api.llm_traces_api import LLMTracesApi
|
||||
from hindsight_client_api.api.memory_api import MemoryApi
|
||||
from hindsight_client_api.api.mental_models_api import MentalModelsApi
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,11 +44,8 @@ from hindsight_client_api.models.consolidation_request import ConsolidationReque
|
||||
from hindsight_client_api.models.consolidation_response import ConsolidationResponse
|
||||
from hindsight_client_api.models.create_bank_request import CreateBankRequest
|
||||
from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest
|
||||
from hindsight_client_api.models.create_folder_request import CreateFolderRequest
|
||||
from hindsight_client_api.models.create_knowledge_page_response import CreateKnowledgePageResponse
|
||||
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
|
||||
from hindsight_client_api.models.create_mental_model_response import CreateMentalModelResponse
|
||||
from hindsight_client_api.models.create_page_request import CreatePageRequest
|
||||
from hindsight_client_api.models.create_webhook_request import CreateWebhookRequest
|
||||
from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse
|
||||
from hindsight_client_api.models.delete_response import DeleteResponse
|
||||
@@ -73,12 +70,6 @@ from hindsight_client_api.models.file_retain_response import FileRetainResponse
|
||||
from hindsight_client_api.models.graph_data_response import GraphDataResponse
|
||||
from hindsight_client_api.models.http_validation_error import HTTPValidationError
|
||||
from hindsight_client_api.models.include_options import IncludeOptions
|
||||
from hindsight_client_api.models.knowledge_node import KnowledgeNode
|
||||
from hindsight_client_api.models.knowledge_page_bundle_file import KnowledgePageBundleFile
|
||||
from hindsight_client_api.models.knowledge_page_bundle_response import KnowledgePageBundleResponse
|
||||
from hindsight_client_api.models.knowledge_page_graph_response import KnowledgePageGraphResponse
|
||||
from hindsight_client_api.models.knowledge_page_response import KnowledgePageResponse
|
||||
from hindsight_client_api.models.knowledge_tree_response import KnowledgeTreeResponse
|
||||
from hindsight_client_api.models.llm_request_entry import LLMRequestEntry
|
||||
from hindsight_client_api.models.llm_request_list_response import LLMRequestListResponse
|
||||
from hindsight_client_api.models.llm_request_stats_bucket import LLMRequestStatsBucket
|
||||
@@ -145,7 +136,6 @@ from hindsight_client_api.models.update_document_request import UpdateDocumentRe
|
||||
from hindsight_client_api.models.update_document_response import UpdateDocumentResponse
|
||||
from hindsight_client_api.models.update_memory_request import UpdateMemoryRequest
|
||||
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
|
||||
from hindsight_client_api.models.update_node_request import UpdateNodeRequest
|
||||
from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest
|
||||
from hindsight_client_api.models.validation_error import ValidationError
|
||||
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.8.3
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class CreateFolderRequest(BaseModel):
|
||||
"""
|
||||
Create a folder under an optional parent folder.
|
||||
""" # noqa: E501
|
||||
name: StrictStr
|
||||
parent_id: Optional[StrictStr] = None
|
||||
__properties: ClassVar[List[str]] = ["name", "parent_id"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of CreateFolderRequest from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# set to None if parent_id (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.parent_id is None and "parent_id" in self.model_fields_set:
|
||||
_dict['parent_id'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of CreateFolderRequest from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"name": obj.get("name"),
|
||||
"parent_id": obj.get("parent_id")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.8.3
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class CreateKnowledgePageResponse(BaseModel):
|
||||
"""
|
||||
Result of creating a page: the node id, its mental model, and the refresh op.
|
||||
""" # noqa: E501
|
||||
page_id: StrictStr
|
||||
mental_model_id: StrictStr
|
||||
operation_id: Optional[StrictStr] = None
|
||||
__properties: ClassVar[List[str]] = ["page_id", "mental_model_id", "operation_id"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of CreateKnowledgePageResponse from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# set to None if operation_id (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.operation_id is None and "operation_id" in self.model_fields_set:
|
||||
_dict['operation_id'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of CreateKnowledgePageResponse from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"page_id": obj.get("page_id"),
|
||||
"mental_model_id": obj.get("mental_model_id"),
|
||||
"operation_id": obj.get("operation_id")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.8.3
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from hindsight_client_api.models.mental_model_trigger_input import MentalModelTriggerInput
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class CreatePageRequest(BaseModel):
|
||||
"""
|
||||
Create a page (a mental model + tree node) under an optional parent folder.
|
||||
""" # noqa: E501
|
||||
name: StrictStr
|
||||
source_query: StrictStr
|
||||
parent_id: Optional[StrictStr] = None
|
||||
tags: Optional[List[StrictStr]] = None
|
||||
max_tokens: Optional[StrictInt] = None
|
||||
trigger: Optional[MentalModelTriggerInput] = None
|
||||
__properties: ClassVar[List[str]] = ["name", "source_query", "parent_id", "tags", "max_tokens", "trigger"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of CreatePageRequest from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# override the default output from pydantic by calling `to_dict()` of trigger
|
||||
if self.trigger:
|
||||
_dict['trigger'] = self.trigger.to_dict()
|
||||
# set to None if parent_id (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.parent_id is None and "parent_id" in self.model_fields_set:
|
||||
_dict['parent_id'] = None
|
||||
|
||||
# set to None if tags (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.tags is None and "tags" in self.model_fields_set:
|
||||
_dict['tags'] = None
|
||||
|
||||
# set to None if max_tokens (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.max_tokens is None and "max_tokens" in self.model_fields_set:
|
||||
_dict['max_tokens'] = None
|
||||
|
||||
# set to None if trigger (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.trigger is None and "trigger" in self.model_fields_set:
|
||||
_dict['trigger'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of CreatePageRequest from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"name": obj.get("name"),
|
||||
"source_query": obj.get("source_query"),
|
||||
"parent_id": obj.get("parent_id"),
|
||||
"tags": obj.get("tags"),
|
||||
"max_tokens": obj.get("max_tokens"),
|
||||
"trigger": MentalModelTriggerInput.from_dict(obj["trigger"]) if obj.get("trigger") is not None else None
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.8.3
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, field_validator
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class KnowledgeNode(BaseModel):
|
||||
"""
|
||||
A node in the knowledge-base tree — a folder or a page. Pages carry ``description``/``tags`` from their backing mental model. The knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node as system-owned vs. hand-authored.
|
||||
""" # noqa: E501
|
||||
id: StrictStr
|
||||
kind: StrictStr
|
||||
name: StrictStr
|
||||
parent_id: Optional[StrictStr] = None
|
||||
mental_model_id: Optional[StrictStr] = None
|
||||
managed: Optional[StrictBool] = Field(default=False, description="Client-set flag: true = system-owned, false = hand-authored.")
|
||||
description: Optional[StrictStr] = None
|
||||
tags: Optional[List[StrictStr]] = None
|
||||
timestamp: Optional[StrictStr] = None
|
||||
children: Optional[List[KnowledgeNode]] = None
|
||||
__properties: ClassVar[List[str]] = ["id", "kind", "name", "parent_id", "mental_model_id", "managed", "description", "tags", "timestamp", "children"]
|
||||
|
||||
@field_validator('kind')
|
||||
def kind_validate_enum(cls, value):
|
||||
"""Validates the enum"""
|
||||
if value not in set(['folder', 'page']):
|
||||
raise ValueError("must be one of enum values ('folder', 'page')")
|
||||
return value
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of KnowledgeNode from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# override the default output from pydantic by calling `to_dict()` of each item in children (list)
|
||||
_items = []
|
||||
if self.children:
|
||||
for _item_children in self.children:
|
||||
if _item_children:
|
||||
_items.append(_item_children.to_dict())
|
||||
_dict['children'] = _items
|
||||
# set to None if parent_id (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.parent_id is None and "parent_id" in self.model_fields_set:
|
||||
_dict['parent_id'] = None
|
||||
|
||||
# set to None if mental_model_id (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.mental_model_id is None and "mental_model_id" in self.model_fields_set:
|
||||
_dict['mental_model_id'] = None
|
||||
|
||||
# set to None if description (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.description is None and "description" in self.model_fields_set:
|
||||
_dict['description'] = None
|
||||
|
||||
# set to None if timestamp (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.timestamp is None and "timestamp" in self.model_fields_set:
|
||||
_dict['timestamp'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of KnowledgeNode from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"id": obj.get("id"),
|
||||
"kind": obj.get("kind"),
|
||||
"name": obj.get("name"),
|
||||
"parent_id": obj.get("parent_id"),
|
||||
"mental_model_id": obj.get("mental_model_id"),
|
||||
"managed": obj.get("managed") if obj.get("managed") is not None else False,
|
||||
"description": obj.get("description"),
|
||||
"tags": obj.get("tags"),
|
||||
"timestamp": obj.get("timestamp"),
|
||||
"children": [KnowledgeNode.from_dict(_item) for _item in obj["children"]] if obj.get("children") is not None else None
|
||||
})
|
||||
return _obj
|
||||
|
||||
# TODO: Rewrite to not use raise_errors
|
||||
KnowledgeNode.model_rebuild(raise_errors=False)
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.8.3
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class KnowledgePageBundleFile(BaseModel):
|
||||
"""
|
||||
One file in a portable OKF bundle.
|
||||
""" # noqa: E501
|
||||
path: StrictStr
|
||||
content: StrictStr
|
||||
__properties: ClassVar[List[str]] = ["path", "content"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of KnowledgePageBundleFile from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of KnowledgePageBundleFile from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"path": obj.get("path"),
|
||||
"content": obj.get("content")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.8.3
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Any, ClassVar, Dict, List
|
||||
from hindsight_client_api.models.knowledge_page_bundle_file import KnowledgePageBundleFile
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class KnowledgePageBundleResponse(BaseModel):
|
||||
"""
|
||||
A portable OKF bundle — a flat set of markdown files (index + pages + logs).
|
||||
""" # noqa: E501
|
||||
files: List[KnowledgePageBundleFile]
|
||||
__properties: ClassVar[List[str]] = ["files"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of KnowledgePageBundleResponse from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# override the default output from pydantic by calling `to_dict()` of each item in files (list)
|
||||
_items = []
|
||||
if self.files:
|
||||
for _item_files in self.files:
|
||||
if _item_files:
|
||||
_items.append(_item_files.to_dict())
|
||||
_dict['files'] = _items
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of KnowledgePageBundleResponse from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"files": [KnowledgePageBundleFile.from_dict(_item) for _item in obj["files"]] if obj.get("files") is not None else None
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.8.3
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, StrictInt
|
||||
from typing import Any, ClassVar, Dict, List
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class KnowledgePageGraphResponse(BaseModel):
|
||||
"""
|
||||
Constellation graph of knowledge pages linked by shared tags.
|
||||
""" # noqa: E501
|
||||
nodes: List[Dict[str, Any]]
|
||||
edges: List[Dict[str, Any]]
|
||||
total_pages: StrictInt
|
||||
total_edges: StrictInt
|
||||
__properties: ClassVar[List[str]] = ["nodes", "edges", "total_pages", "total_edges"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of KnowledgePageGraphResponse from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of KnowledgePageGraphResponse from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"nodes": obj.get("nodes"),
|
||||
"edges": obj.get("edges"),
|
||||
"total_pages": obj.get("total_pages"),
|
||||
"total_edges": obj.get("total_edges")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.8.3
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class KnowledgePageResponse(BaseModel):
|
||||
"""
|
||||
A knowledge page rendered as an OKF document.
|
||||
""" # noqa: E501
|
||||
id: StrictStr
|
||||
name: StrictStr
|
||||
type: StrictStr = Field(description="OKF document type — from a `type:<x>` tag, else 'knowledge-page'.")
|
||||
description: Optional[StrictStr] = None
|
||||
tags: Optional[List[StrictStr]] = None
|
||||
timestamp: Optional[StrictStr] = None
|
||||
body: Optional[StrictStr] = None
|
||||
markdown: StrictStr = Field(description="The full OKF document: YAML frontmatter + markdown body.")
|
||||
__properties: ClassVar[List[str]] = ["id", "name", "type", "description", "tags", "timestamp", "body", "markdown"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of KnowledgePageResponse from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# set to None if description (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.description is None and "description" in self.model_fields_set:
|
||||
_dict['description'] = None
|
||||
|
||||
# set to None if timestamp (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.timestamp is None and "timestamp" in self.model_fields_set:
|
||||
_dict['timestamp'] = None
|
||||
|
||||
# set to None if body (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.body is None and "body" in self.model_fields_set:
|
||||
_dict['body'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of KnowledgePageResponse from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"id": obj.get("id"),
|
||||
"name": obj.get("name"),
|
||||
"type": obj.get("type"),
|
||||
"description": obj.get("description"),
|
||||
"tags": obj.get("tags"),
|
||||
"timestamp": obj.get("timestamp"),
|
||||
"body": obj.get("body"),
|
||||
"markdown": obj.get("markdown")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.8.3
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Any, ClassVar, Dict, List
|
||||
from hindsight_client_api.models.knowledge_node import KnowledgeNode
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class KnowledgeTreeResponse(BaseModel):
|
||||
"""
|
||||
The knowledge base as a nested folder/page tree.
|
||||
""" # noqa: E501
|
||||
roots: List[KnowledgeNode]
|
||||
__properties: ClassVar[List[str]] = ["roots"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of KnowledgeTreeResponse from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# override the default output from pydantic by calling `to_dict()` of each item in roots (list)
|
||||
_items = []
|
||||
if self.roots:
|
||||
for _item_roots in self.roots:
|
||||
if _item_roots:
|
||||
_items.append(_item_roots.to_dict())
|
||||
_dict['roots'] = _items
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of KnowledgeTreeResponse from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"roots": [KnowledgeNode.from_dict(_item) for _item in obj["roots"]] if obj.get("roots") is not None else None
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.8.3
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class UpdateNodeRequest(BaseModel):
|
||||
"""
|
||||
Rename and/or move a node. Each field applies only when present.
|
||||
""" # noqa: E501
|
||||
name: Optional[StrictStr] = None
|
||||
parent_id: Optional[StrictStr] = None
|
||||
__properties: ClassVar[List[str]] = ["name", "parent_id"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of UpdateNodeRequest from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# set to None if name (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.name is None and "name" in self.model_fields_set:
|
||||
_dict['name'] = None
|
||||
|
||||
# set to None if parent_id (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.parent_id is None and "parent_id" in self.model_fields_set:
|
||||
_dict['parent_id'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of UpdateNodeRequest from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"name": obj.get("name"),
|
||||
"parent_id": obj.get("parent_id")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -32,12 +32,6 @@ import type {
|
||||
CreateDirectiveData,
|
||||
CreateDirectiveErrors,
|
||||
CreateDirectiveResponses,
|
||||
CreateKnowledgeFolderData,
|
||||
CreateKnowledgeFolderErrors,
|
||||
CreateKnowledgeFolderResponses,
|
||||
CreateKnowledgePageData,
|
||||
CreateKnowledgePageErrors,
|
||||
CreateKnowledgePageResponses,
|
||||
CreateMentalModelData,
|
||||
CreateMentalModelErrors,
|
||||
CreateMentalModelResponses,
|
||||
@@ -56,9 +50,6 @@ import type {
|
||||
DeleteDocumentData,
|
||||
DeleteDocumentErrors,
|
||||
DeleteDocumentResponses,
|
||||
DeleteKnowledgeNodeData,
|
||||
DeleteKnowledgeNodeErrors,
|
||||
DeleteKnowledgeNodeResponses,
|
||||
DeleteMentalModelData,
|
||||
DeleteMentalModelErrors,
|
||||
DeleteMentalModelResponses,
|
||||
@@ -74,9 +65,6 @@ import type {
|
||||
ExportDocumentsData,
|
||||
ExportDocumentsErrors,
|
||||
ExportDocumentsResponses,
|
||||
ExportKnowledgeBaseData,
|
||||
ExportKnowledgeBaseErrors,
|
||||
ExportKnowledgeBaseResponses,
|
||||
FileRetainData,
|
||||
FileRetainErrors,
|
||||
FileRetainResponses,
|
||||
@@ -109,15 +97,6 @@ import type {
|
||||
GetGraphData,
|
||||
GetGraphErrors,
|
||||
GetGraphResponses,
|
||||
GetKnowledgeBaseGraphData,
|
||||
GetKnowledgeBaseGraphErrors,
|
||||
GetKnowledgeBaseGraphResponses,
|
||||
GetKnowledgeBaseTreeData,
|
||||
GetKnowledgeBaseTreeErrors,
|
||||
GetKnowledgeBaseTreeResponses,
|
||||
GetKnowledgePageData,
|
||||
GetKnowledgePageErrors,
|
||||
GetKnowledgePageResponses,
|
||||
GetMemoriesTimeseriesData,
|
||||
GetMemoriesTimeseriesErrors,
|
||||
GetMemoriesTimeseriesResponses,
|
||||
@@ -241,9 +220,6 @@ import type {
|
||||
UpdateDocumentData,
|
||||
UpdateDocumentErrors,
|
||||
UpdateDocumentResponses,
|
||||
UpdateKnowledgeNodeData,
|
||||
UpdateKnowledgeNodeErrors,
|
||||
UpdateKnowledgeNodeResponses,
|
||||
UpdateMemoryData,
|
||||
UpdateMemoryErrors,
|
||||
UpdateMemoryResponses,
|
||||
@@ -678,138 +654,6 @@ export const clearMentalModel = <ThrowOnError extends boolean = false>(
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the knowledge-base tree
|
||||
*
|
||||
* Return the knowledge base as a nested tree of folders and pages.
|
||||
*/
|
||||
export const getKnowledgeBaseTree = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetKnowledgeBaseTreeData, ThrowOnError>
|
||||
) =>
|
||||
(options.client ?? client).get<
|
||||
GetKnowledgeBaseTreeResponses,
|
||||
GetKnowledgeBaseTreeErrors,
|
||||
ThrowOnError
|
||||
>({ url: "/v1/default/banks/{bank_id}/knowledge-base/tree", ...options });
|
||||
|
||||
/**
|
||||
* Create a knowledge-base folder
|
||||
*
|
||||
* Create a folder, optionally nested under a parent folder.
|
||||
*/
|
||||
export const createKnowledgeFolder = <ThrowOnError extends boolean = false>(
|
||||
options: Options<CreateKnowledgeFolderData, ThrowOnError>
|
||||
) =>
|
||||
(options.client ?? client).post<
|
||||
CreateKnowledgeFolderResponses,
|
||||
CreateKnowledgeFolderErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/v1/default/banks/{bank_id}/knowledge-base/folders",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a knowledge-base page
|
||||
*
|
||||
* Create a page (a mental model + tree node). Content is generated asynchronously; use the returned operation_id to track completion.
|
||||
*/
|
||||
export const createKnowledgePage = <ThrowOnError extends boolean = false>(
|
||||
options: Options<CreateKnowledgePageData, ThrowOnError>
|
||||
) =>
|
||||
(options.client ?? client).post<
|
||||
CreateKnowledgePageResponses,
|
||||
CreateKnowledgePageErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/v1/default/banks/{bank_id}/knowledge-base/pages",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Knowledge-base constellation graph
|
||||
*
|
||||
* Return pages as nodes linked by shared tags, for the constellation view.
|
||||
*/
|
||||
export const getKnowledgeBaseGraph = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetKnowledgeBaseGraphData, ThrowOnError>
|
||||
) =>
|
||||
(options.client ?? client).get<
|
||||
GetKnowledgeBaseGraphResponses,
|
||||
GetKnowledgeBaseGraphErrors,
|
||||
ThrowOnError
|
||||
>({ url: "/v1/default/banks/{bank_id}/knowledge-base/graph", ...options });
|
||||
|
||||
/**
|
||||
* Export the knowledge base as an OKF bundle
|
||||
*
|
||||
* Return a portable OKF bundle: a nested index.md, one <id>.md per page, and history logs.
|
||||
*/
|
||||
export const exportKnowledgeBase = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ExportKnowledgeBaseData, ThrowOnError>
|
||||
) =>
|
||||
(options.client ?? client).get<
|
||||
ExportKnowledgeBaseResponses,
|
||||
ExportKnowledgeBaseErrors,
|
||||
ThrowOnError
|
||||
>({ url: "/v1/default/banks/{bank_id}/knowledge-base/export", ...options });
|
||||
|
||||
/**
|
||||
* Get a knowledge-base page
|
||||
*
|
||||
* Return a single page as an OKF document (frontmatter + markdown body).
|
||||
*/
|
||||
export const getKnowledgePage = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetKnowledgePageData, ThrowOnError>
|
||||
) =>
|
||||
(options.client ?? client).get<GetKnowledgePageResponses, GetKnowledgePageErrors, ThrowOnError>({
|
||||
url: "/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a knowledge-base node
|
||||
*
|
||||
* Delete a folder or page and its whole subtree (pages' mental models are removed too).
|
||||
*/
|
||||
export const deleteKnowledgeNode = <ThrowOnError extends boolean = false>(
|
||||
options: Options<DeleteKnowledgeNodeData, ThrowOnError>
|
||||
) =>
|
||||
(options.client ?? client).delete<
|
||||
DeleteKnowledgeNodeResponses,
|
||||
DeleteKnowledgeNodeErrors,
|
||||
ThrowOnError
|
||||
>({ url: "/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}", ...options });
|
||||
|
||||
/**
|
||||
* Rename or move a knowledge-base node
|
||||
*
|
||||
* Rename a node (set `name`) and/or move it under another folder (set `parent_id`, null for the root).
|
||||
*/
|
||||
export const updateKnowledgeNode = <ThrowOnError extends boolean = false>(
|
||||
options: Options<UpdateKnowledgeNodeData, ThrowOnError>
|
||||
) =>
|
||||
(options.client ?? client).patch<
|
||||
UpdateKnowledgeNodeResponses,
|
||||
UpdateKnowledgeNodeErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* List directives
|
||||
*
|
||||
|
||||
@@ -453,7 +453,7 @@ export type BankTemplateConfig = {
|
||||
/**
|
||||
* Retain Extraction Mode
|
||||
*
|
||||
* Fact extraction mode: 'concise' (default), 'verbose', or 'custom'
|
||||
* Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'
|
||||
*/
|
||||
retain_extraction_mode?: string | null;
|
||||
/**
|
||||
@@ -1080,7 +1080,7 @@ export type CreateBankRequest = {
|
||||
/**
|
||||
* Retain Extraction Mode
|
||||
*
|
||||
* Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.
|
||||
* Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.
|
||||
*/
|
||||
retain_extraction_mode?: string | null;
|
||||
/**
|
||||
@@ -1153,42 +1153,6 @@ export type CreateDirectiveRequest = {
|
||||
tags?: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* CreateFolderRequest
|
||||
*
|
||||
* Create a folder under an optional parent folder.
|
||||
*/
|
||||
export type CreateFolderRequest = {
|
||||
/**
|
||||
* Name
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Parent Id
|
||||
*/
|
||||
parent_id?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* CreateKnowledgePageResponse
|
||||
*
|
||||
* Result of creating a page: the node id, its mental model, and the refresh op.
|
||||
*/
|
||||
export type CreateKnowledgePageResponse = {
|
||||
/**
|
||||
* Page Id
|
||||
*/
|
||||
page_id: string;
|
||||
/**
|
||||
* Mental Model Id
|
||||
*/
|
||||
mental_model_id: string;
|
||||
/**
|
||||
* Operation Id
|
||||
*/
|
||||
operation_id?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* CreateMentalModelRequest
|
||||
*
|
||||
@@ -1251,35 +1215,6 @@ export type CreateMentalModelResponse = {
|
||||
operation_id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* CreatePageRequest
|
||||
*
|
||||
* Create a page (a mental model + tree node) under an optional parent folder.
|
||||
*/
|
||||
export type CreatePageRequest = {
|
||||
/**
|
||||
* Name
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Source Query
|
||||
*/
|
||||
source_query: string;
|
||||
/**
|
||||
* Parent Id
|
||||
*/
|
||||
parent_id?: string | null;
|
||||
/**
|
||||
* Tags
|
||||
*/
|
||||
tags?: Array<string> | null;
|
||||
/**
|
||||
* Max Tokens
|
||||
*/
|
||||
max_tokens?: number | null;
|
||||
trigger?: MentalModelTriggerInput | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* CreateWebhookRequest
|
||||
*
|
||||
@@ -2021,184 +1956,6 @@ export type IncludeOptions = {
|
||||
source_facts?: SourceFactsIncludeOptions | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* KnowledgeNode
|
||||
*
|
||||
* A node in the knowledge-base tree — a folder or a page.
|
||||
*
|
||||
* Pages carry ``description``/``tags`` from their backing mental model. The
|
||||
* knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node
|
||||
* as system-owned vs. hand-authored.
|
||||
*/
|
||||
export type KnowledgeNode = {
|
||||
/**
|
||||
* Id
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Kind
|
||||
*/
|
||||
kind: "folder" | "page";
|
||||
/**
|
||||
* Name
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Parent Id
|
||||
*/
|
||||
parent_id?: string | null;
|
||||
/**
|
||||
* Mental Model Id
|
||||
*
|
||||
* Backing mental model id (pages only).
|
||||
*/
|
||||
mental_model_id?: string | null;
|
||||
/**
|
||||
* Managed
|
||||
*
|
||||
* Client-set flag: true = system-owned, false = hand-authored.
|
||||
*/
|
||||
managed?: boolean;
|
||||
/**
|
||||
* Description
|
||||
*
|
||||
* Page source query (OKF `description`).
|
||||
*/
|
||||
description?: string | null;
|
||||
/**
|
||||
* Tags
|
||||
*/
|
||||
tags?: Array<string>;
|
||||
/**
|
||||
* Timestamp
|
||||
*
|
||||
* Last refresh (page) or last update (folder).
|
||||
*/
|
||||
timestamp?: string | null;
|
||||
/**
|
||||
* Children
|
||||
*/
|
||||
children?: Array<KnowledgeNode>;
|
||||
};
|
||||
|
||||
/**
|
||||
* KnowledgePageBundleFile
|
||||
*
|
||||
* One file in a portable OKF bundle.
|
||||
*/
|
||||
export type KnowledgePageBundleFile = {
|
||||
/**
|
||||
* Path
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* Content
|
||||
*/
|
||||
content: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* KnowledgePageBundleResponse
|
||||
*
|
||||
* A portable OKF bundle — a flat set of markdown files (index + pages + logs).
|
||||
*/
|
||||
export type KnowledgePageBundleResponse = {
|
||||
/**
|
||||
* Files
|
||||
*/
|
||||
files: Array<KnowledgePageBundleFile>;
|
||||
};
|
||||
|
||||
/**
|
||||
* KnowledgePageGraphResponse
|
||||
*
|
||||
* Constellation graph of knowledge pages linked by shared tags.
|
||||
*/
|
||||
export type KnowledgePageGraphResponse = {
|
||||
/**
|
||||
* Nodes
|
||||
*/
|
||||
nodes: Array<{
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
/**
|
||||
* Edges
|
||||
*/
|
||||
edges: Array<{
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
/**
|
||||
* Total Pages
|
||||
*/
|
||||
total_pages: number;
|
||||
/**
|
||||
* Total Edges
|
||||
*/
|
||||
total_edges: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* KnowledgePageResponse
|
||||
*
|
||||
* A knowledge page rendered as an OKF document.
|
||||
*/
|
||||
export type KnowledgePageResponse = {
|
||||
/**
|
||||
* Id
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Name
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Type
|
||||
*
|
||||
* OKF document type — from a `type:<x>` tag, else 'knowledge-page'.
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Description
|
||||
*
|
||||
* The source query that rebuilds the page.
|
||||
*/
|
||||
description?: string | null;
|
||||
/**
|
||||
* Tags
|
||||
*/
|
||||
tags?: Array<string>;
|
||||
/**
|
||||
* Timestamp
|
||||
*
|
||||
* Last refresh time (falls back to creation).
|
||||
*/
|
||||
timestamp?: string | null;
|
||||
/**
|
||||
* Body
|
||||
*
|
||||
* The page's synthesized markdown body.
|
||||
*/
|
||||
body?: string | null;
|
||||
/**
|
||||
* Markdown
|
||||
*
|
||||
* The full OKF document: YAML frontmatter + markdown body.
|
||||
*/
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* KnowledgeTreeResponse
|
||||
*
|
||||
* The knowledge base as a nested folder/page tree.
|
||||
*/
|
||||
export type KnowledgeTreeResponse = {
|
||||
/**
|
||||
* Roots
|
||||
*/
|
||||
roots: Array<KnowledgeNode>;
|
||||
};
|
||||
|
||||
/**
|
||||
* LLMRequestEntry
|
||||
*
|
||||
@@ -4181,22 +3938,6 @@ export type UpdateMentalModelRequest = {
|
||||
trigger?: MentalModelTriggerInput | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* UpdateNodeRequest
|
||||
*
|
||||
* Rename and/or move a node. Each field applies only when present.
|
||||
*/
|
||||
export type UpdateNodeRequest = {
|
||||
/**
|
||||
* Name
|
||||
*/
|
||||
name?: string | null;
|
||||
/**
|
||||
* Parent Id
|
||||
*/
|
||||
parent_id?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* UpdateWebhookRequest
|
||||
*
|
||||
@@ -5530,313 +5271,6 @@ export type ClearMentalModelResponses = {
|
||||
|
||||
export type ClearMentalModelResponse = ClearMentalModelResponses[keyof ClearMentalModelResponses];
|
||||
|
||||
export type GetKnowledgeBaseTreeData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/knowledge-base/tree";
|
||||
};
|
||||
|
||||
export type GetKnowledgeBaseTreeErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetKnowledgeBaseTreeError =
|
||||
GetKnowledgeBaseTreeErrors[keyof GetKnowledgeBaseTreeErrors];
|
||||
|
||||
export type GetKnowledgeBaseTreeResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: KnowledgeTreeResponse;
|
||||
};
|
||||
|
||||
export type GetKnowledgeBaseTreeResponse =
|
||||
GetKnowledgeBaseTreeResponses[keyof GetKnowledgeBaseTreeResponses];
|
||||
|
||||
export type CreateKnowledgeFolderData = {
|
||||
body: CreateFolderRequest;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/knowledge-base/folders";
|
||||
};
|
||||
|
||||
export type CreateKnowledgeFolderErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type CreateKnowledgeFolderError =
|
||||
CreateKnowledgeFolderErrors[keyof CreateKnowledgeFolderErrors];
|
||||
|
||||
export type CreateKnowledgeFolderResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
201: KnowledgeNode;
|
||||
};
|
||||
|
||||
export type CreateKnowledgeFolderResponse =
|
||||
CreateKnowledgeFolderResponses[keyof CreateKnowledgeFolderResponses];
|
||||
|
||||
export type CreateKnowledgePageData = {
|
||||
body: CreatePageRequest;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/knowledge-base/pages";
|
||||
};
|
||||
|
||||
export type CreateKnowledgePageErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type CreateKnowledgePageError = CreateKnowledgePageErrors[keyof CreateKnowledgePageErrors];
|
||||
|
||||
export type CreateKnowledgePageResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
201: CreateKnowledgePageResponse;
|
||||
};
|
||||
|
||||
export type CreateKnowledgePageResponse2 =
|
||||
CreateKnowledgePageResponses[keyof CreateKnowledgePageResponses];
|
||||
|
||||
export type GetKnowledgeBaseGraphData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/knowledge-base/graph";
|
||||
};
|
||||
|
||||
export type GetKnowledgeBaseGraphErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetKnowledgeBaseGraphError =
|
||||
GetKnowledgeBaseGraphErrors[keyof GetKnowledgeBaseGraphErrors];
|
||||
|
||||
export type GetKnowledgeBaseGraphResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: KnowledgePageGraphResponse;
|
||||
};
|
||||
|
||||
export type GetKnowledgeBaseGraphResponse =
|
||||
GetKnowledgeBaseGraphResponses[keyof GetKnowledgeBaseGraphResponses];
|
||||
|
||||
export type ExportKnowledgeBaseData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/knowledge-base/export";
|
||||
};
|
||||
|
||||
export type ExportKnowledgeBaseErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type ExportKnowledgeBaseError = ExportKnowledgeBaseErrors[keyof ExportKnowledgeBaseErrors];
|
||||
|
||||
export type ExportKnowledgeBaseResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: KnowledgePageBundleResponse;
|
||||
};
|
||||
|
||||
export type ExportKnowledgeBaseResponse =
|
||||
ExportKnowledgeBaseResponses[keyof ExportKnowledgeBaseResponses];
|
||||
|
||||
export type GetKnowledgePageData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
/**
|
||||
* Page Id
|
||||
*/
|
||||
page_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/knowledge-base/pages/{page_id}";
|
||||
};
|
||||
|
||||
export type GetKnowledgePageErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetKnowledgePageError = GetKnowledgePageErrors[keyof GetKnowledgePageErrors];
|
||||
|
||||
export type GetKnowledgePageResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: KnowledgePageResponse;
|
||||
};
|
||||
|
||||
export type GetKnowledgePageResponse = GetKnowledgePageResponses[keyof GetKnowledgePageResponses];
|
||||
|
||||
export type DeleteKnowledgeNodeData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
/**
|
||||
* Node Id
|
||||
*/
|
||||
node_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}";
|
||||
};
|
||||
|
||||
export type DeleteKnowledgeNodeErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type DeleteKnowledgeNodeError = DeleteKnowledgeNodeErrors[keyof DeleteKnowledgeNodeErrors];
|
||||
|
||||
export type DeleteKnowledgeNodeResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type UpdateKnowledgeNodeData = {
|
||||
body: UpdateNodeRequest;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
/**
|
||||
* Node Id
|
||||
*/
|
||||
node_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/knowledge-base/nodes/{node_id}";
|
||||
};
|
||||
|
||||
export type UpdateKnowledgeNodeErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type UpdateKnowledgeNodeError = UpdateKnowledgeNodeErrors[keyof UpdateKnowledgeNodeErrors];
|
||||
|
||||
export type UpdateKnowledgeNodeResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: KnowledgeNode;
|
||||
};
|
||||
|
||||
export type UpdateKnowledgeNodeResponse =
|
||||
UpdateKnowledgeNodeResponses[keyof UpdateKnowledgeNodeResponses];
|
||||
|
||||
export type ListDirectivesData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
||||
@@ -500,7 +500,7 @@ export class HindsightClient {
|
||||
dispositionEmpathy?: number;
|
||||
/** Steers what gets extracted during retain(). Injected alongside built-in rules. */
|
||||
retainMission?: string;
|
||||
/** Fact extraction mode: 'concise' (default), 'verbose', or 'custom'. */
|
||||
/** Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'. */
|
||||
retainExtractionMode?: string;
|
||||
/** Custom extraction prompt (only active when retainExtractionMode is 'custom'). */
|
||||
retainCustomInstructions?: string;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"public"
|
||||
],
|
||||
"scripts": {
|
||||
"prebuild": "npm run build -w @vectorize-io/hindsight-client",
|
||||
"dev": "next dev --turbopack -p ${PORT:-9999}",
|
||||
"build": "NODE_ENV=production next build && npm run build:standalone",
|
||||
"build:standalone": "rm -rf standalone && SERVER_JS=$(find .next/standalone -path '*/node_modules' -prune -o -name 'server.js' -print | head -1) && test -n \"$SERVER_JS\" || (echo 'Error: server.js not found in .next/standalone - standalone build failed' && exit 1) && STANDALONE_ROOT=$(dirname \"$SERVER_JS\") && cp -r \"$STANDALONE_ROOT\" standalone && cp -r .next/standalone/node_modules standalone/node_modules && mkdir -p standalone/.next && cp -r .next/static standalone/.next/static && mkdir -p standalone/public && (cp -r public/* standalone/public/ 2>/dev/null || true)",
|
||||
@@ -38,14 +39,12 @@
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.5",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
@@ -55,8 +54,6 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cron-parser": "^5.6.1",
|
||||
"cronstrue": "^3.21.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"lucide-react": "^0.553.0",
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Sidebar } from "@/components/sidebar";
|
||||
import { DataView } from "@/components/data-view";
|
||||
import { DocumentsView } from "@/components/documents-view";
|
||||
import { EntitiesView } from "@/components/entities-view";
|
||||
import { KnowledgeBaseView } from "@/components/knowledge-base-view";
|
||||
import { ThinkView } from "@/components/think-view";
|
||||
import { SearchDebugView } from "@/components/search-debug-view";
|
||||
import { BankProfileView } from "@/components/bank-profile-view";
|
||||
@@ -58,7 +57,7 @@ import {
|
||||
import { LlmHealthDialog } from "@/components/llm-health-dialog";
|
||||
import { ExtractDialog } from "@/components/extract-dialog";
|
||||
|
||||
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "knowledge" | "profile";
|
||||
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile";
|
||||
type DataSubTab = "world" | "experience" | "observations" | "mental-models";
|
||||
type BankConfigTab =
|
||||
| "general"
|
||||
@@ -639,13 +638,6 @@ export default function BankPage() {
|
||||
<EntitiesView />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Knowledge base Tab — KnowledgeBaseView renders its own header. */}
|
||||
{view === "knowledge" && (
|
||||
<div>
|
||||
<KnowledgeBaseView />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank
|
||||
const { searchParams } = new URL(request.url);
|
||||
const tags = searchParams.getAll("tags");
|
||||
const tagsMatch = searchParams.get("tags_match");
|
||||
const limit = searchParams.get("limit");
|
||||
const offset = searchParams.get("offset");
|
||||
|
||||
if (!bankId) {
|
||||
return NextResponse.json(
|
||||
@@ -26,6 +28,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank
|
||||
if (tagsMatch) {
|
||||
queryParams.append("tags_match", tagsMatch);
|
||||
}
|
||||
if (limit) {
|
||||
queryParams.append("limit", limit);
|
||||
}
|
||||
if (offset) {
|
||||
queryParams.append("offset", offset);
|
||||
}
|
||||
|
||||
const url = dataplaneBankUrl(
|
||||
bankId,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
|
||||
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const bankId = request.nextUrl.searchParams.get("bank_id");
|
||||
if (!bankId) {
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "bank_id is required",
|
||||
errorKey: "api.errors.validation.bankIdRequired",
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const response = await fetch(dataplaneBankUrl(bankId, "/knowledge-base/export"), {
|
||||
headers: getDataplaneHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
return NextResponse.json(error, { status: response.status });
|
||||
}
|
||||
return NextResponse.json(await response.json(), { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Failed to export knowledge base:", error);
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "Failed to export knowledge base",
|
||||
errorKey: "api.errors.knowledgeBase.export",
|
||||
}),
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
|
||||
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const bankId = request.nextUrl.searchParams.get("bank_id");
|
||||
if (!bankId) {
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "bank_id is required",
|
||||
errorKey: "api.errors.validation.bankIdRequired",
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const body = await request.json();
|
||||
const response = await fetch(dataplaneBankUrl(bankId, "/knowledge-base/folders"), {
|
||||
method: "POST",
|
||||
headers: getDataplaneHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
return NextResponse.json(error, { status: response.status });
|
||||
}
|
||||
return NextResponse.json(await response.json(), { status: response.status });
|
||||
} catch (error) {
|
||||
console.error("Failed to create folder:", error);
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "Failed to create folder",
|
||||
errorKey: "api.errors.knowledgeBase.createFolder",
|
||||
}),
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
|
||||
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const bankId = request.nextUrl.searchParams.get("bank_id");
|
||||
if (!bankId) {
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "bank_id is required",
|
||||
errorKey: "api.errors.validation.bankIdRequired",
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const response = await fetch(dataplaneBankUrl(bankId, "/knowledge-base/graph"), {
|
||||
headers: getDataplaneHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
return NextResponse.json(error, { status: response.status });
|
||||
}
|
||||
return NextResponse.json(await response.json(), { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch knowledge base graph:", error);
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "Failed to fetch knowledge base graph",
|
||||
errorKey: "api.errors.knowledgeBase.graph",
|
||||
}),
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
|
||||
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
|
||||
|
||||
function bankId(request: NextRequest): string | null {
|
||||
return request.nextUrl.searchParams.get("bank_id");
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ nodeId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { nodeId } = await params;
|
||||
const bank = bankId(request);
|
||||
if (!bank) {
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "bank_id is required",
|
||||
errorKey: "api.errors.validation.bankIdRequired",
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const body = await request.json();
|
||||
const response = await fetch(
|
||||
dataplaneBankUrl(
|
||||
bank,
|
||||
`/knowledge-base/nodes/${encodeURIComponent(decodeURIComponent(nodeId))}`
|
||||
),
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: getDataplaneHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
return NextResponse.json(error, { status: response.status });
|
||||
}
|
||||
return NextResponse.json(await response.json(), { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Failed to update node:", error);
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "Failed to update node",
|
||||
errorKey: "api.errors.knowledgeBase.updateNode",
|
||||
}),
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ nodeId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { nodeId } = await params;
|
||||
const bank = bankId(request);
|
||||
if (!bank) {
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "bank_id is required",
|
||||
errorKey: "api.errors.validation.bankIdRequired",
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const response = await fetch(
|
||||
dataplaneBankUrl(
|
||||
bank,
|
||||
`/knowledge-base/nodes/${encodeURIComponent(decodeURIComponent(nodeId))}`
|
||||
),
|
||||
{ method: "DELETE", headers: getDataplaneHeaders() }
|
||||
);
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
return NextResponse.json(error, { status: response.status });
|
||||
}
|
||||
return NextResponse.json(await response.json(), { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete node:", error);
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "Failed to delete node",
|
||||
errorKey: "api.errors.knowledgeBase.deleteNode",
|
||||
}),
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
|
||||
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ pageId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { pageId } = await params;
|
||||
const bankId = request.nextUrl.searchParams.get("bank_id");
|
||||
if (!bankId) {
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "bank_id is required",
|
||||
errorKey: "api.errors.validation.bankIdRequired",
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const response = await fetch(
|
||||
dataplaneBankUrl(
|
||||
bankId,
|
||||
`/knowledge-base/pages/${encodeURIComponent(decodeURIComponent(pageId))}`
|
||||
),
|
||||
{ headers: getDataplaneHeaders() }
|
||||
);
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
return NextResponse.json(error, { status: response.status });
|
||||
}
|
||||
return NextResponse.json(await response.json(), { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch knowledge page:", error);
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "Failed to fetch knowledge page",
|
||||
errorKey: "api.errors.knowledgeBase.fetchPage",
|
||||
}),
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
|
||||
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const bankId = request.nextUrl.searchParams.get("bank_id");
|
||||
if (!bankId) {
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "bank_id is required",
|
||||
errorKey: "api.errors.validation.bankIdRequired",
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const body = await request.json();
|
||||
const response = await fetch(dataplaneBankUrl(bankId, "/knowledge-base/pages"), {
|
||||
method: "POST",
|
||||
headers: getDataplaneHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
return NextResponse.json(error, { status: response.status });
|
||||
}
|
||||
return NextResponse.json(await response.json(), { status: response.status });
|
||||
} catch (error) {
|
||||
console.error("Failed to create page:", error);
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "Failed to create page",
|
||||
errorKey: "api.errors.knowledgeBase.createPage",
|
||||
}),
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
|
||||
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const bankId = request.nextUrl.searchParams.get("bank_id");
|
||||
if (!bankId) {
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "bank_id is required",
|
||||
errorKey: "api.errors.validation.bankIdRequired",
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const response = await fetch(dataplaneBankUrl(bankId, "/knowledge-base/tree"), {
|
||||
headers: getDataplaneHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
return NextResponse.json(error, { status: response.status });
|
||||
}
|
||||
return NextResponse.json(await response.json(), { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch knowledge base tree:", error);
|
||||
return NextResponse.json(
|
||||
localizeApiErrorPayload(request, {
|
||||
error: "Failed to fetch knowledge base tree",
|
||||
errorKey: "api.errors.knowledgeBase.tree",
|
||||
}),
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useRef, useEffect, useCallback, useMemo, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { prepare, layout, prepareWithSegments, layoutWithLines } from "@chenglou/pretext";
|
||||
import type { GraphData, GraphNode, GraphLink } from "./graph-2d";
|
||||
import type { GraphData, GraphNode, GraphLink } from "./graph-data";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -21,6 +21,12 @@ interface PreparedNode {
|
||||
/** Color derived from link count (heat gradient) */
|
||||
heatColor: string;
|
||||
linkCount: number;
|
||||
/**
|
||||
* Per-node phase in [0, 2π), derived from the id hash. Desynchronizes the
|
||||
* ambient drift + pulse so the field breathes organically instead of in
|
||||
* lockstep. Precomputed here so the animation loop stays trig-only.
|
||||
*/
|
||||
phase: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -372,6 +378,7 @@ export function Constellation({
|
||||
// so the grouping reads at a glance; otherwise it keeps the heat gradient.
|
||||
heatColor: centroid ? color : heat,
|
||||
linkCount: lc,
|
||||
phase: ((Math.abs(seed) % 1000) / 1000) * Math.PI * 2,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -448,15 +455,24 @@ export function Constellation({
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Screen positions
|
||||
// Ambient-motion clock (seconds).
|
||||
const time = (typeof performance !== "undefined" ? performance.now() : 0) / 1000;
|
||||
// Drift amplitude in world units — nodes slowly wander around their home
|
||||
// position so the whole field visibly breathes.
|
||||
const DRIFT_AMP = 16;
|
||||
|
||||
// Screen positions (with a slow per-node ambient drift baked in, so links —
|
||||
// which read straight from screenX/screenY below — follow for free).
|
||||
const screenX = new Float32Array(preparedNodes.length);
|
||||
const screenY = new Float32Array(preparedNodes.length);
|
||||
const visible = new Uint8Array(preparedNodes.length);
|
||||
|
||||
for (let i = 0; i < preparedNodes.length; i++) {
|
||||
const n = preparedNodes[i];
|
||||
const sx = cx + n.wx * zoom;
|
||||
const sy = cy + n.wy * zoom;
|
||||
const driftX = DRIFT_AMP * Math.sin(time * 0.6 + n.phase);
|
||||
const driftY = DRIFT_AMP * Math.cos(time * 0.5 + n.phase * 1.3);
|
||||
const sx = cx + (n.wx + driftX) * zoom;
|
||||
const sy = cy + (n.wy + driftY) * zoom;
|
||||
screenX[i] = sx;
|
||||
screenY[i] = sy;
|
||||
visible[i] = sx > -margin && sx < W + margin && sy > -margin && sy < H + margin ? 1 : 0;
|
||||
@@ -508,11 +524,39 @@ export function Constellation({
|
||||
ctx.moveTo(ax, ay);
|
||||
ctx.quadraticCurveTo(midX, midY, bx, by);
|
||||
ctx.stroke();
|
||||
|
||||
// A small bead of light travels the curve from the hovered node outward,
|
||||
// so connections read as live signal paths rather than static lines.
|
||||
{
|
||||
// Phase-offset per link so beads don't march in lockstep. Travel runs
|
||||
// from the hovered node (u=0) toward its neighbor (u=1).
|
||||
const fromHovered = link.a === hoverIndex;
|
||||
const raw = (time * 0.22 + (li % 13) / 13) % 1;
|
||||
const u = fromHovered ? raw : 1 - raw;
|
||||
const iu = 1 - u;
|
||||
// Point on the quadratic Bézier at parameter u.
|
||||
const px = iu * iu * ax + 2 * iu * u * midX + u * u * bx;
|
||||
const py = iu * iu * ay + 2 * iu * u * midY + u * u * by;
|
||||
ctx.globalAlpha = 0.9 * (0.4 + 0.6 * Math.sin(u * Math.PI)); // fade at the ends
|
||||
ctx.fillStyle = link.color;
|
||||
ctx.shadowColor = link.color;
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
// Restore the stroke state the loop's next iteration expects.
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.lineWidth = 1.5;
|
||||
}
|
||||
linksDrawn++;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
} else {
|
||||
const baseAlpha = 0.06 + Math.min(zoom * 0.04, 0.1);
|
||||
// Faint, slow breathing across the whole web so idle links feel alive
|
||||
// without flickering (one global sine, not per-link — stays calm).
|
||||
const shimmer = 1 + 0.18 * Math.sin(time * 0.6);
|
||||
const baseAlpha = (0.06 + Math.min(zoom * 0.04, 0.1)) * shimmer;
|
||||
ctx.lineWidth = 0.4;
|
||||
|
||||
for (const link of linksWithIndices) {
|
||||
@@ -661,11 +705,18 @@ export function Constellation({
|
||||
// Size varies slightly by link count — subtle range like star magnitudes.
|
||||
// When nodeSizeFn is provided (e.g. entities view), it overrides linkCount
|
||||
// sizing so dots can scale by an external weight like co-occurrence count.
|
||||
const baseR = nodeSizeFn ? nodeSizeFn(n.node) : 2.5 + Math.min(n.linkCount * 0.15, 2.5);
|
||||
const rawR = nodeSizeFn ? nodeSizeFn(n.node) : 2.5 + Math.min(n.linkCount * 0.15, 2.5);
|
||||
// Gentle pulse — each dot "breathes" in size, out of phase with its
|
||||
// neighbors, so the field twinkles like a living star map.
|
||||
const pulse = 1 + 0.13 * Math.sin(time * 1.05 + n.phase);
|
||||
const baseR = rawR * pulse;
|
||||
const r = Math.max(1.5, baseR * Math.min(zoom, 2));
|
||||
|
||||
// Opacity varies — fewer links = dimmer, more links = brighter
|
||||
const baseAlpha = 0.45 + Math.min(n.linkCount * 0.03, 0.5);
|
||||
// Opacity varies — fewer links = dimmer, more links = brighter. A brightness
|
||||
// twinkle (offset from the size pulse) makes even tiny dots read as alive,
|
||||
// where a radius pulse alone would be imperceptible.
|
||||
const twinkleAlpha = 0.82 + 0.18 * Math.sin(time * 1.4 + n.phase * 2.1);
|
||||
const baseAlpha = (0.45 + Math.min(n.linkCount * 0.03, 0.5)) * twinkleAlpha;
|
||||
|
||||
// Dot — star-like: heat-gradient color, varied size & opacity
|
||||
ctx.beginPath();
|
||||
@@ -679,12 +730,14 @@ export function Constellation({
|
||||
}
|
||||
ctx.fill();
|
||||
|
||||
// Soft glow halo for brighter stars (high link count)
|
||||
// Soft glow halo for brighter stars (high link count) — the halo twinkles
|
||||
// a little (out of phase with the dot's pulse) so hubs feel radiant.
|
||||
if (n.linkCount > 3 && !isHovered && hoverIndex < 0) {
|
||||
const twinkle = 1 + 0.25 * Math.sin(time * 0.9 + n.phase * 1.7);
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, r * 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = n.heatColor;
|
||||
ctx.globalAlpha = 0.06 + Math.min(n.linkCount * 0.005, 0.08);
|
||||
ctx.globalAlpha = (0.06 + Math.min(n.linkCount * 0.005, 0.08)) * twinkle;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
@@ -1119,9 +1172,17 @@ export function Constellation({
|
||||
canvas.addEventListener("mouseup", handleMouseUp);
|
||||
canvas.addEventListener("mouseleave", handleMouseLeave);
|
||||
|
||||
// The canvas also changes size when its container reflows (e.g. the side
|
||||
// panel opening/closing) with no window "resize" event. Observe the element
|
||||
// so the backing store is re-measured — otherwise CSS stretches the old
|
||||
// bitmap and the text/dots look squeezed.
|
||||
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => resize()) : null;
|
||||
ro?.observe(canvas);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
ro?.disconnect();
|
||||
canvas.removeEventListener("wheel", handleWheel);
|
||||
canvas.removeEventListener("mousemove", handleMouseMove);
|
||||
canvas.removeEventListener("mousedown", handleMouseDown);
|
||||
|
||||
@@ -16,11 +16,9 @@ import {
|
||||
ChevronsRight,
|
||||
Settings2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Network,
|
||||
List,
|
||||
Search,
|
||||
Layers,
|
||||
@@ -33,8 +31,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
@@ -43,16 +39,15 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { MemoryDetailPanel } from "./memory-detail-panel";
|
||||
import { MemoryDetailModal } from "./memory-detail-modal";
|
||||
import { Graph2D, convertHindsightGraphData, GraphNode } from "./graph-2d";
|
||||
import { convertHindsightGraphData, GraphNode } from "./graph-data";
|
||||
import { Constellation } from "./constellation";
|
||||
import { TagFilterInput } from "./tag-filter-input";
|
||||
import { ObservationScopeFilter, ObservationScope } from "./observation-scope-filter";
|
||||
import { ScatterChart, Plus, FileText } from "lucide-react";
|
||||
|
||||
type FactType = "world" | "experience" | "observation";
|
||||
type ViewMode = "graph" | "table" | "timeline" | "constellation";
|
||||
type ViewMode = "table" | "timeline" | "constellation";
|
||||
|
||||
// Categorical palette for coloring observation scopes (exact tag sets) when
|
||||
// "Group by scope" clusters the constellation. Distinct, reasonably separable hues.
|
||||
@@ -105,7 +100,6 @@ export function DataView({
|
||||
const [scopes, setScopes] = useState<ObservationScope[]>([]);
|
||||
const [selectedScope, setSelectedScope] = useState<string[] | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedGraphNode, setSelectedGraphNode] = useState<any>(null);
|
||||
const [modalMemoryId, setModalMemoryId] = useState<string | null>(null);
|
||||
// Table view: toggle between live facts (graph-fed) and invalidated facts (archive).
|
||||
const [showInvalidated, setShowInvalidated] = useState(false);
|
||||
@@ -132,10 +126,7 @@ export function DataView({
|
||||
last_consolidated_at: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Graph controls state
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const [maxNodes, setMaxNodes] = useState<number | undefined>(undefined);
|
||||
const [showControlPanel, setShowControlPanel] = useState(true);
|
||||
// Constellation controls state
|
||||
const [visibleLinkTypes, setVisibleLinkTypes] = useState<Set<string>>(
|
||||
new Set(["semantic", "temporal", "entity", "causal"])
|
||||
);
|
||||
@@ -152,17 +143,6 @@ export function DataView({
|
||||
});
|
||||
};
|
||||
|
||||
// Esc key handler to deselect graph node
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && selectedGraphNode) {
|
||||
setSelectedGraphNode(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedGraphNode]);
|
||||
|
||||
// `silent` skips the loading spinner — used by the background consolidation
|
||||
// poll so the view refreshes in place without flashing.
|
||||
const loadData = async (
|
||||
@@ -230,6 +210,8 @@ export function DataView({
|
||||
if (showInvalidated) return invalidatedRows;
|
||||
return data?.table_rows ?? [];
|
||||
}, [data, showInvalidated, invalidatedRows]);
|
||||
const hasActiveMemoryFilters =
|
||||
searchQuery.trim().length > 0 || tagFilters.length > 0 || selectedScope !== null;
|
||||
|
||||
// Helper to get normalized link type
|
||||
const getLinkTypeCategory = (type: string | undefined): string => {
|
||||
@@ -239,7 +221,7 @@ export function DataView({
|
||||
return "semantic";
|
||||
};
|
||||
|
||||
// Convert data for Graph2D (graph data is already filtered server-side)
|
||||
// Convert data for the constellation (graph data is already filtered server-side)
|
||||
const graph2DData = useMemo(() => {
|
||||
if (!data) return { nodes: [], links: [] };
|
||||
const fullData = convertHindsightGraphData(data);
|
||||
@@ -253,44 +235,11 @@ export function DataView({
|
||||
return { nodes: fullData.nodes, links };
|
||||
}, [data, visibleLinkTypes]);
|
||||
|
||||
// Calculate link stats for display
|
||||
const linkStats = useMemo(() => {
|
||||
let semantic = 0,
|
||||
temporal = 0,
|
||||
entity = 0,
|
||||
causal = 0,
|
||||
total = 0;
|
||||
const otherTypes: Record<string, number> = {};
|
||||
graph2DData.links.forEach((l) => {
|
||||
total++;
|
||||
const type = l.type || "unknown";
|
||||
if (type === "semantic") semantic++;
|
||||
else if (type === "temporal") temporal++;
|
||||
else if (type === "entity") entity++;
|
||||
else if (
|
||||
type === "causes" ||
|
||||
type === "caused_by" ||
|
||||
type === "enables" ||
|
||||
type === "prevents"
|
||||
)
|
||||
causal++;
|
||||
else {
|
||||
otherTypes[type] = (otherTypes[type] || 0) + 1;
|
||||
}
|
||||
});
|
||||
return { semantic, temporal, entity, causal, total, otherTypes };
|
||||
}, [graph2DData]);
|
||||
|
||||
// Handle node click in graph - show in panel
|
||||
const handleGraphNodeClick = useCallback(
|
||||
(node: GraphNode) => {
|
||||
const nodeData = data?.table_rows?.find((row: any) => row.id === node.id);
|
||||
if (nodeData) {
|
||||
setSelectedGraphNode(nodeData);
|
||||
}
|
||||
},
|
||||
[data]
|
||||
);
|
||||
const handleGraphNodeClick = useCallback((node: GraphNode) => {
|
||||
// Open the memory dialog for the clicked node (same dialog the table/timeline use).
|
||||
setModalMemoryId(node.id);
|
||||
}, []);
|
||||
|
||||
// Memoized color functions to prevent graph re-initialization
|
||||
// Uses brand colors: primary blue (#0074d9), teal (#009296), amber for entity, purple for causal
|
||||
@@ -509,19 +458,6 @@ export function DataView({
|
||||
return () => clearInterval(id);
|
||||
}, [isConsolidating, currentBank]);
|
||||
|
||||
// Enforce 50 node limit to prevent UI instability, default to 20 or max whichever is smaller
|
||||
useEffect(() => {
|
||||
if (data && maxNodes === undefined) {
|
||||
if (graph2DData.nodes.length > 50) {
|
||||
// Always set maxNodes to 20 when we have >50 nodes (never leave as undefined)
|
||||
setMaxNodes(20);
|
||||
} else if (graph2DData.nodes.length > 20) {
|
||||
setMaxNodes(20);
|
||||
}
|
||||
// If ≤20 nodes, leave maxNodes undefined to show all
|
||||
}
|
||||
}, [data, graph2DData.nodes.length, maxNodes]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{loading && !data ? (
|
||||
@@ -529,7 +465,7 @@ export function DataView({
|
||||
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
|
||||
<p className="text-muted-foreground">{t("loadingMemories")}</p>
|
||||
</div>
|
||||
) : data && data.total_units === 0 ? (
|
||||
) : data && data.total_units === 0 && !hasActiveMemoryFilters ? (
|
||||
<div className="text-center py-20">
|
||||
<FileText className="w-10 h-10 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-base font-medium text-foreground mb-1">{t("noMemoriesYet")}</h3>
|
||||
@@ -644,7 +580,7 @@ export function DataView({
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{searchQuery || tagFilters.length > 0 ? (
|
||||
{hasActiveMemoryFilters ? (
|
||||
t("matchingMemories", { count: filteredTableRows.length })
|
||||
) : data.table_rows?.length < data.total_units ? (
|
||||
<span>
|
||||
@@ -656,11 +592,8 @@ export function DataView({
|
||||
onClick={() => {
|
||||
const newLimit = Math.min(data.total_units, fetchLimit + 1000);
|
||||
setFetchLimit(newLimit);
|
||||
loadData(
|
||||
newLimit,
|
||||
searchQuery || undefined,
|
||||
tagFilters.length > 0 ? tagFilters : undefined
|
||||
);
|
||||
const { tags, match } = resolveTagQuery();
|
||||
loadData(newLimit, searchQuery || undefined, tags, match);
|
||||
}}
|
||||
className="ml-2 text-primary hover:underline"
|
||||
>
|
||||
@@ -705,11 +638,10 @@ export function DataView({
|
||||
{t("pendingCount", { count: consolidationStatus.pending_consolidation })}
|
||||
<button
|
||||
onClick={() =>
|
||||
loadData(
|
||||
fetchLimit,
|
||||
searchQuery || undefined,
|
||||
tagFilters.length > 0 ? tagFilters : undefined
|
||||
)
|
||||
(() => {
|
||||
const { tags, match } = resolveTagQuery();
|
||||
loadData(fetchLimit, searchQuery || undefined, tags, match);
|
||||
})()
|
||||
}
|
||||
disabled={loading}
|
||||
className="ml-0.5 opacity-70 hover:opacity-100 disabled:opacity-40 transition-opacity"
|
||||
@@ -734,17 +666,6 @@ export function DataView({
|
||||
<ScatterChart className="w-4 h-4" />
|
||||
{t("constellation")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("graph")}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||||
viewMode === "graph"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Network className="w-4 h-4" />
|
||||
{t("graph")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("table")}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||||
@@ -771,244 +692,76 @@ export function DataView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compactMode && viewMode === "graph" && (
|
||||
<div className="flex gap-0">
|
||||
{/* Graph */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Graph2D
|
||||
data={graph2DData}
|
||||
height={700}
|
||||
showLabels={showLabels}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
maxNodes={maxNodes}
|
||||
nodeColorFn={nodeColorFn}
|
||||
linkColorFn={linkColorFn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Toggle Button */}
|
||||
<button
|
||||
onClick={() => setShowControlPanel(!showControlPanel)}
|
||||
className="flex-shrink-0 w-5 h-[700px] bg-transparent hover:bg-muted/50 flex items-center justify-center transition-colors"
|
||||
title={showControlPanel ? t("hidePanel") : t("showPanel")}
|
||||
>
|
||||
{showControlPanel ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/60" />
|
||||
) : (
|
||||
<ChevronLeft className="w-3 h-3 text-muted-foreground/60" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Right Panel - Legend/Controls OR Memory Details */}
|
||||
<div
|
||||
className={`${showControlPanel ? "w-80" : "w-0"} transition-all duration-300 overflow-hidden flex-shrink-0`}
|
||||
>
|
||||
<div className="w-80 h-[700px] bg-card border-l border-border overflow-y-auto">
|
||||
{selectedGraphNode ? (
|
||||
/* Memory Detail View */
|
||||
<MemoryDetailPanel
|
||||
memory={selectedGraphNode}
|
||||
onClose={() => setSelectedGraphNode(null)}
|
||||
inPanel
|
||||
bankId={currentBank || undefined}
|
||||
/>
|
||||
) : (
|
||||
/* Legend & Controls View */
|
||||
<div className="p-4 space-y-5">
|
||||
{/* Legend & Stats */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-foreground">
|
||||
{t("graphTitle")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{/* Nodes */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: "#0074d9" }}
|
||||
/>
|
||||
<span className="text-foreground">{t("nodes")}</span>
|
||||
</div>
|
||||
<span className="font-mono text-foreground">
|
||||
{Math.min(
|
||||
maxNodes ?? graph2DData.nodes.length,
|
||||
graph2DData.nodes.length
|
||||
)}
|
||||
/{graph2DData.nodes.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs font-medium text-muted-foreground mt-2 mb-1">
|
||||
{t("linksWithCount", { count: linkStats.total })}{" "}
|
||||
<span className="text-muted-foreground/60">{t("clickToFilter")}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleLinkType("semantic")}
|
||||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||||
visibleLinkTypes.has("semantic")
|
||||
? "hover:bg-muted"
|
||||
: "opacity-40 hover:opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[#0074d9]" />
|
||||
<span className="text-foreground">{t("semantic")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-mono ${linkStats.semantic === 0 ? "text-destructive" : "text-foreground"}`}
|
||||
>
|
||||
{linkStats.semantic}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleLinkType("temporal")}
|
||||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||||
visibleLinkTypes.has("temporal")
|
||||
? "hover:bg-muted"
|
||||
: "opacity-40 hover:opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[#009296]" />
|
||||
<span className="text-foreground">{t("temporal")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-mono ${linkStats.temporal === 0 ? "text-destructive" : "text-foreground"}`}
|
||||
>
|
||||
{linkStats.temporal}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleLinkType("entity")}
|
||||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||||
visibleLinkTypes.has("entity")
|
||||
? "hover:bg-muted"
|
||||
: "opacity-40 hover:opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[#f59e0b]" />
|
||||
<span className="text-foreground">{t("entity")}</span>
|
||||
</div>
|
||||
<span className="font-mono text-foreground">{linkStats.entity}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleLinkType("causal")}
|
||||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||||
visibleLinkTypes.has("causal")
|
||||
? "hover:bg-muted"
|
||||
: "opacity-40 hover:opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[#8b5cf6]" />
|
||||
<span className="text-foreground">{t("causal")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-mono ${linkStats.causal === 0 ? "text-muted-foreground" : "text-foreground"}`}
|
||||
>
|
||||
{linkStats.causal}
|
||||
</span>
|
||||
</button>
|
||||
{Object.entries(linkStats.otherTypes || {}).map(([type, count]) => (
|
||||
<div key={type} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground capitalize ml-6">{type}</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{count as number}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Controls Section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-foreground">
|
||||
{t("displayTitle")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-labels" className="text-sm text-foreground">
|
||||
{t("showLabels")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="show-labels"
|
||||
checked={showLabels}
|
||||
onCheckedChange={setShowLabels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Limits Section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-foreground">
|
||||
{t("performanceTitle")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label className="text-sm text-foreground">{t("maxNodes")}</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{graph2DData.nodes.length > 50
|
||||
? `${maxNodes ?? 50} / ${graph2DData.nodes.length}`
|
||||
: `${maxNodes ?? "All"} / ${graph2DData.nodes.length}`}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[
|
||||
graph2DData.nodes.length > 50
|
||||
? maxNodes || 20
|
||||
: maxNodes || Math.min(graph2DData.nodes.length, 20),
|
||||
]}
|
||||
min={10}
|
||||
max={Math.min(Math.max(graph2DData.nodes.length, 10), 50)}
|
||||
step={10}
|
||||
onValueChange={([v]) => {
|
||||
const effectiveMax = Math.min(graph2DData.nodes.length, 50);
|
||||
// If we have >50 nodes, never allow "All" (undefined), cap at 50
|
||||
if (graph2DData.nodes.length > 50) {
|
||||
setMaxNodes(v);
|
||||
} else {
|
||||
// Original behavior for ≤50 nodes: allow "All" when slider reaches max
|
||||
setMaxNodes(v >= effectiveMax ? undefined : v);
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("allLinksVisible")}
|
||||
{graph2DData.nodes.length > 50 && (
|
||||
<span className="block text-amber-600 dark:text-amber-400 mt-1">
|
||||
{t("limitedTo50Nodes", { count: graph2DData.nodes.length })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Hint */}
|
||||
<div className="text-xs text-muted-foreground/60 text-center pt-2">
|
||||
{t("clickNodeForDetails")}
|
||||
</div>
|
||||
{(compactMode || viewMode === "constellation") && (
|
||||
<div className="space-y-3">
|
||||
{/* Constellation controls — moved out of the old side panel to sit
|
||||
inline above the graph, next to the view toggle / filters. */}
|
||||
{!compactMode && (
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
{factType === "observation" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("groupByScope")}
|
||||
</span>
|
||||
<Switch checked={groupByScope} onCheckedChange={setGroupByScope} />
|
||||
</div>
|
||||
)}
|
||||
{!(factType === "observation" && groupByScope) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("colorBy")}
|
||||
</span>
|
||||
<Select
|
||||
value={recencyBasis}
|
||||
onValueChange={(v) => setRecencyBasis(v as RecencyBasis)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mentioned_at">{t("mentioned")}</SelectItem>
|
||||
<SelectItem value="occurred_start">{t("occurredStart")}</SelectItem>
|
||||
<SelectItem value="occurred_end">{t("occurredEnd")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("linkTypes")}
|
||||
</span>
|
||||
{Object.entries({
|
||||
semantic: "#0074d9",
|
||||
temporal: "#009296",
|
||||
entity: "#f59e0b",
|
||||
causal: "#8b5cf6",
|
||||
}).map(([type, color]) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => toggleLinkType(type)}
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
opacity: visibleLinkTypes.has(type) ? 1 : 0.2,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs capitalize ${visibleLinkTypes.has(type) ? "text-foreground" : "text-muted-foreground line-through"}`}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{(compactMode || viewMode === "constellation") && (
|
||||
<div className="flex gap-0">
|
||||
<div className="flex-1 min-w-0 border border-border rounded-lg overflow-hidden">
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Constellation
|
||||
key={compactMode ? "compact" : "full"}
|
||||
data={graph2DData}
|
||||
@@ -1049,119 +802,6 @@ export function DataView({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Toggle Button + Panel (hidden in compact mode) */}
|
||||
{!compactMode && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowControlPanel(!showControlPanel)}
|
||||
className="flex-shrink-0 w-5 h-[700px] bg-transparent hover:bg-muted/50 flex items-center justify-center transition-colors"
|
||||
title={showControlPanel ? t("hidePanel") : t("showPanel")}
|
||||
>
|
||||
{showControlPanel ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronLeft className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Right Panel — reuse the same panel as graph view */}
|
||||
{showControlPanel && (
|
||||
<div className="w-72 flex-shrink-0 border border-border rounded-lg bg-muted/20 overflow-y-auto h-[700px]">
|
||||
{selectedGraphNode ? (
|
||||
<MemoryDetailPanel
|
||||
memory={selectedGraphNode}
|
||||
onClose={() => setSelectedGraphNode(null)}
|
||||
inPanel
|
||||
bankId={currentBank || undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{t("constellationViewTitle")}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("constellationViewDescription")}
|
||||
</p>
|
||||
{factType === "observation" && (
|
||||
<div className="flex items-center justify-between gap-2 pt-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
{t("groupByScope")}
|
||||
</h4>
|
||||
</div>
|
||||
<Switch checked={groupByScope} onCheckedChange={setGroupByScope} />
|
||||
</div>
|
||||
)}
|
||||
{!(factType === "observation" && groupByScope) && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
{t("colorBy")}
|
||||
</h4>
|
||||
<Select
|
||||
value={recencyBasis}
|
||||
onValueChange={(v) => setRecencyBasis(v as RecencyBasis)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mentioned_at">{t("mentioned")}</SelectItem>
|
||||
<SelectItem value="occurred_start">
|
||||
{t("occurredStart")}
|
||||
</SelectItem>
|
||||
<SelectItem value="occurred_end">{t("occurredEnd")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2 pt-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
{t("linkTypes")}
|
||||
</h4>
|
||||
{Object.entries({
|
||||
semantic: "#0074d9",
|
||||
temporal: "#009296",
|
||||
entity: "#f59e0b",
|
||||
causal: "#8b5cf6",
|
||||
}).map(([type, color]) => (
|
||||
<div
|
||||
key={type}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => toggleLinkType(type)}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
opacity: visibleLinkTypes.has(type) ? 1 : 0.2,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs capitalize ${visibleLinkTypes.has(type) ? "text-foreground" : "text-muted-foreground line-through"}`}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1 pt-2">
|
||||
<div>
|
||||
{t("nodes")}:{" "}
|
||||
<span className="text-foreground">{graph2DData.nodes.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
{t("links")}:{" "}
|
||||
<span className="text-foreground">{graph2DData.links.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1396,9 +1036,7 @@ export function DataView({
|
||||
})()
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{data.table_rows?.length > 0
|
||||
? t("noMemoriesMatchFilter")
|
||||
: t("noMemoriesFound")}
|
||||
{hasActiveMemoryFilters ? t("noMemoriesMatchFilter") : t("noMemoriesFound")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Constellation } from "./constellation";
|
||||
import { convertHindsightGraphData, GraphNode } from "./graph-2d";
|
||||
import { convertHindsightGraphData, GraphNode } from "./graph-data";
|
||||
|
||||
type EntityGraphResponse = Awaited<ReturnType<typeof client.getEntityGraph>>;
|
||||
|
||||
|
||||
@@ -1,726 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import cytoscape from "cytoscape";
|
||||
|
||||
import fcose from "cytoscape-fcose";
|
||||
|
||||
// Register the fcose extension
|
||||
cytoscape.use(fcose);
|
||||
|
||||
// Hook to detect dark mode
|
||||
function useIsDarkMode() {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDark = () => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"));
|
||||
};
|
||||
|
||||
checkDark();
|
||||
|
||||
// Watch for theme changes
|
||||
const observer = new MutationObserver(checkDark);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDark;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Types & Interfaces
|
||||
// ============================================================================
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
group?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GraphLink {
|
||||
source: string;
|
||||
target: string;
|
||||
color?: string;
|
||||
width?: number;
|
||||
type?: string;
|
||||
entity?: string;
|
||||
weight?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
export interface Graph2DProps {
|
||||
data: GraphData;
|
||||
height?: number;
|
||||
showLabels?: boolean;
|
||||
onNodeClick?: (node: GraphNode) => void;
|
||||
onNodeHover?: (node: GraphNode | null) => void;
|
||||
nodeColorFn?: (node: GraphNode) => string;
|
||||
nodeSizeFn?: (node: GraphNode) => number;
|
||||
linkColorFn?: (link: GraphLink) => string;
|
||||
linkWidthFn?: (link: GraphLink) => number;
|
||||
maxNodes?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Values
|
||||
// ============================================================================
|
||||
|
||||
// Brand colors
|
||||
const BRAND_PRIMARY = "#0074d9";
|
||||
const LINK_SEMANTIC = "#0074d9"; // Primary blue for semantic
|
||||
|
||||
const DEFAULT_NODE_COLOR = BRAND_PRIMARY;
|
||||
const DEFAULT_LINK_COLOR = LINK_SEMANTIC;
|
||||
const DEFAULT_LINK_WIDTH = 1;
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export function Graph2D({
|
||||
data,
|
||||
height = 600,
|
||||
showLabels = true,
|
||||
onNodeClick,
|
||||
onNodeHover,
|
||||
nodeColorFn,
|
||||
nodeSizeFn,
|
||||
linkColorFn,
|
||||
linkWidthFn,
|
||||
maxNodes,
|
||||
}: Graph2DProps) {
|
||||
const t = useTranslations("graph2d");
|
||||
const [containerDiv, setContainerDiv] = useState<HTMLDivElement | null>(null);
|
||||
const cyRef = useRef<any>(null);
|
||||
const isInitializingRef = useRef(false);
|
||||
const lastDataSignatureRef = useRef<string>("");
|
||||
const [_hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
|
||||
const [hoveredLink, setHoveredLink] = useState<GraphLink | null>(null);
|
||||
const [linkTooltipPos, setLinkTooltipPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isFocusMode, setIsFocusMode] = useState(false);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// Use refs to store callbacks and data to prevent re-renders from resetting the graph
|
||||
const onNodeClickRef = useRef(onNodeClick);
|
||||
const onNodeHoverRef = useRef(onNodeHover);
|
||||
const fullDataRef = useRef(data);
|
||||
const nodeColorFnRef = useRef(nodeColorFn);
|
||||
const linkColorFnRef = useRef(linkColorFn);
|
||||
const isFocusModeRef = useRef(isFocusMode);
|
||||
onNodeClickRef.current = onNodeClick;
|
||||
onNodeHoverRef.current = onNodeHover;
|
||||
fullDataRef.current = data;
|
||||
nodeColorFnRef.current = nodeColorFn;
|
||||
linkColorFnRef.current = linkColorFn;
|
||||
isFocusModeRef.current = isFocusMode;
|
||||
|
||||
// Transform and limit data - only limit nodes, show ALL links between visible nodes
|
||||
const graphData = useMemo(() => {
|
||||
let nodes = [...data.nodes];
|
||||
|
||||
// Limit nodes if needed
|
||||
if (maxNodes && nodes.length > maxNodes) {
|
||||
nodes = nodes.slice(0, maxNodes);
|
||||
}
|
||||
|
||||
// Show ALL links between visible nodes (no random link limiting)
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
const links = data.links.filter((l) => nodeIds.has(l.source) && nodeIds.has(l.target));
|
||||
|
||||
return { nodes, links };
|
||||
}, [data, maxNodes]);
|
||||
|
||||
// Track mounting state
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
return () => setIsMounted(false);
|
||||
}, []);
|
||||
|
||||
// Convert to Cytoscape format
|
||||
const cyElements = useMemo(() => {
|
||||
// Calculate node importance based on connections
|
||||
const nodeConnections = new Map<string, number>();
|
||||
graphData.links.forEach((link) => {
|
||||
nodeConnections.set(link.source, (nodeConnections.get(link.source) || 0) + 1);
|
||||
nodeConnections.set(link.target, (nodeConnections.get(link.target) || 0) + 1);
|
||||
});
|
||||
|
||||
const nodes = graphData.nodes.map((node) => {
|
||||
const connections = nodeConnections.get(node.id) || 0;
|
||||
const dynamicSize = nodeSizeFn
|
||||
? nodeSizeFn(node)
|
||||
: Math.max(16, Math.min(40, 16 + connections * 4)); // Smaller, more subtle sizing
|
||||
|
||||
return {
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.label || node.id.substring(0, 8),
|
||||
color: nodeColorFn ? nodeColorFn(node) : node.color || DEFAULT_NODE_COLOR,
|
||||
size: node.size || dynamicSize,
|
||||
originalNode: node,
|
||||
connections: connections,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const edges = graphData.links.map((link, idx) => ({
|
||||
data: {
|
||||
id: `edge-${idx}`,
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
color: linkColorFn ? linkColorFn(link) : link.color || DEFAULT_LINK_COLOR,
|
||||
width: linkWidthFn ? linkWidthFn(link) : link.width || DEFAULT_LINK_WIDTH,
|
||||
type: link.type,
|
||||
entity: link.entity,
|
||||
weight: link.weight,
|
||||
originalLink: link,
|
||||
},
|
||||
}));
|
||||
|
||||
return [...nodes, ...edges];
|
||||
}, [graphData, nodeColorFn, nodeSizeFn, linkColorFn, linkWidthFn]);
|
||||
|
||||
// Create data signature to prevent double initialization
|
||||
const dataSignature = useMemo(() => {
|
||||
return JSON.stringify({
|
||||
nodeCount: graphData.nodes.length,
|
||||
linkCount: graphData.links.length,
|
||||
nodeIds: graphData.nodes
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join(","),
|
||||
showLabels,
|
||||
isDarkMode,
|
||||
maxNodes,
|
||||
});
|
||||
}, [graphData.nodes, graphData.links, showLabels, isDarkMode, maxNodes]);
|
||||
|
||||
// Initialize Cytoscape
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
// Small delay to ensure container is mounted
|
||||
const timeout = setTimeout(() => {
|
||||
if (isCancelled || !isMounted || !containerDiv || isInitializingRef.current) return;
|
||||
|
||||
// Check if data has actually changed to prevent double initialization
|
||||
if (lastDataSignatureRef.current === dataSignature) {
|
||||
console.log("Data signature unchanged, skipping graph initialization");
|
||||
return;
|
||||
}
|
||||
|
||||
// Additional validation - check if element has dimensions
|
||||
const rect = containerDiv.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
console.warn("Container has no dimensions, skipping cytoscape initialization");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle empty data case
|
||||
if (cyElements.length === 0) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we already have a graph with the same data
|
||||
if (cyRef.current && !cyRef.current.destroyed()) {
|
||||
const currentNodes = cyRef.current.nodes().length;
|
||||
const currentEdges = cyRef.current.edges().length;
|
||||
const newNodes = cyElements.filter((el) => !(el.data as any).source).length;
|
||||
const newEdges = cyElements.filter((el) => (el.data as any).source).length;
|
||||
|
||||
// If the element counts are the same, just update styles and skip reinitialization
|
||||
if (currentNodes === newNodes && currentEdges === newEdges) {
|
||||
console.log("Graph already initialized with same data, skipping reinitialization");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up existing graph before creating new one
|
||||
console.log("Data changed, destroying existing graph");
|
||||
cyRef.current.destroy();
|
||||
cyRef.current = null;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
isInitializingRef.current = true;
|
||||
|
||||
// Theme-aware colors
|
||||
const textColor = isDarkMode ? "#ffffff" : "#1f2937";
|
||||
const textBgColor = isDarkMode ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.9)";
|
||||
|
||||
try {
|
||||
console.log("Initializing cytoscape with container:", containerDiv);
|
||||
console.log("Elements count:", cyElements.length);
|
||||
console.log("Sample elements:", cyElements.slice(0, 2));
|
||||
|
||||
// Try minimal initialization first
|
||||
const cy = cytoscape({
|
||||
container: containerDiv,
|
||||
elements: [],
|
||||
// Disable edge selection to prevent gray border on click
|
||||
selectionType: "single",
|
||||
userZoomingEnabled: true,
|
||||
userPanningEnabled: true,
|
||||
boxSelectionEnabled: false,
|
||||
// Disable automatic layout on initialization
|
||||
layout: { name: "preset" },
|
||||
style: [
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
"background-color": "data(color)",
|
||||
width: "data(size)",
|
||||
height: "data(size)",
|
||||
label: showLabels ? "data(label)" : "",
|
||||
color: textColor,
|
||||
"text-valign": "bottom",
|
||||
"text-halign": "center",
|
||||
"font-size": "8px",
|
||||
"font-weight": 500,
|
||||
"text-margin-y": 3,
|
||||
"text-wrap": "wrap",
|
||||
"text-max-width": "80px",
|
||||
"text-background-color": textBgColor,
|
||||
"text-background-opacity": 0.9,
|
||||
"text-background-padding": "2px",
|
||||
"text-background-shape": "roundrectangle",
|
||||
"border-width": 1,
|
||||
"border-color": isDarkMode ? "#ffffff20" : "#00000020",
|
||||
"border-opacity": 0.3,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "node:selected",
|
||||
style: {
|
||||
"border-width": 3,
|
||||
"border-color": "#0074d9",
|
||||
"border-opacity": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: "data(width)",
|
||||
"line-color": "data(color)",
|
||||
"target-arrow-color": "data(color)",
|
||||
"target-arrow-shape": "triangle",
|
||||
"target-arrow-size": 6,
|
||||
"curve-style": "bezier",
|
||||
opacity: isDarkMode ? 0.6 : 0.7,
|
||||
},
|
||||
},
|
||||
// Focus mode styles
|
||||
{
|
||||
selector: ".dimmed",
|
||||
style: {
|
||||
opacity: 0.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: ".focused",
|
||||
style: {
|
||||
"border-width": 4,
|
||||
"border-color": "#ff6b35",
|
||||
"border-opacity": 1,
|
||||
"z-index": 999,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: ".connected",
|
||||
style: {
|
||||
"border-width": 2,
|
||||
"border-color": "#0074d9",
|
||||
"border-opacity": 0.8,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge.connection",
|
||||
style: {
|
||||
width: 2,
|
||||
opacity: 1,
|
||||
"z-index": 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge.connection:hover",
|
||||
style: {
|
||||
width: 3,
|
||||
opacity: 1,
|
||||
"z-index": 200,
|
||||
},
|
||||
},
|
||||
// Disable edge selection styling
|
||||
{
|
||||
selector: "edge:selected",
|
||||
style: {
|
||||
"overlay-opacity": 0,
|
||||
"overlay-color": "transparent",
|
||||
"overlay-padding": 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
cyRef.current = cy;
|
||||
|
||||
console.log("Cytoscape initialized successfully");
|
||||
|
||||
// Add elements after initialization
|
||||
if (cyElements.length > 0) {
|
||||
console.log("Adding elements to cytoscape");
|
||||
cy.add(cyElements);
|
||||
cy.layout({
|
||||
name: "fcose",
|
||||
quality: "default",
|
||||
randomize: false,
|
||||
animate: true,
|
||||
animationDuration: 1500,
|
||||
// Separation settings - increase to spread nodes more
|
||||
nodeSeparation: 200,
|
||||
idealEdgeLength: () => 250,
|
||||
edgeElasticity: () => 0.05,
|
||||
nestingFactor: 0.05,
|
||||
gravity: 0.05, // Reduced gravity spreads nodes more
|
||||
numIter: 2500,
|
||||
// Overlap prevention
|
||||
nodeOverlap: 30,
|
||||
avoidOverlap: true,
|
||||
nodeDimensionsIncludeLabels: true,
|
||||
// Layout bounds - reduce padding to use more space
|
||||
padding: 20,
|
||||
boundingBox: undefined,
|
||||
// Tiling - increase spacing between disconnected components
|
||||
tile: true,
|
||||
tilingPaddingVertical: 30,
|
||||
tilingPaddingHorizontal: 30,
|
||||
// Force more spread
|
||||
uniformNodeDimensions: false,
|
||||
packComponents: false, // Don't pack components tightly
|
||||
}).run();
|
||||
|
||||
// Fit to viewport
|
||||
cy.fit();
|
||||
}
|
||||
|
||||
// Add basic interactions
|
||||
cy.on("tap", "node", (evt: any) => {
|
||||
const node = evt.target as cytoscape.NodeSingular;
|
||||
const originalNode = node.data("originalNode") as GraphNode;
|
||||
if (onNodeClickRef.current && originalNode) {
|
||||
onNodeClickRef.current(originalNode);
|
||||
}
|
||||
});
|
||||
|
||||
cy.on("mouseover", "node", (evt: any) => {
|
||||
const node = evt.target as cytoscape.NodeSingular;
|
||||
const originalNode = node.data("originalNode") as GraphNode;
|
||||
setHoveredNode(originalNode);
|
||||
if (onNodeHoverRef.current && originalNode) {
|
||||
onNodeHoverRef.current(originalNode);
|
||||
}
|
||||
if (containerDiv) containerDiv.style.cursor = "pointer";
|
||||
});
|
||||
|
||||
cy.on("mouseout", "node", () => {
|
||||
setHoveredNode(null);
|
||||
if (onNodeHoverRef.current) {
|
||||
onNodeHoverRef.current(null);
|
||||
}
|
||||
if (containerDiv) containerDiv.style.cursor = "default";
|
||||
});
|
||||
|
||||
// Edge hover handlers - only work in focus mode and on highlighted edges
|
||||
cy.on("mouseover", "edge", (evt: any) => {
|
||||
const edge = evt.target;
|
||||
|
||||
// Only allow interaction if we're in focus mode and edge is highlighted
|
||||
if (!isFocusModeRef.current || !edge.hasClass("connection")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalLink = edge.data("originalLink") as GraphLink;
|
||||
if (originalLink) {
|
||||
setHoveredLink(originalLink);
|
||||
// Get position for tooltip
|
||||
const renderedPos = edge.renderedMidpoint();
|
||||
setLinkTooltipPos({ x: renderedPos.x, y: renderedPos.y });
|
||||
}
|
||||
});
|
||||
|
||||
cy.on("mouseout", "edge", (evt: any) => {
|
||||
const edge = evt.target;
|
||||
|
||||
// Only clear hover state if we were actually hovering a highlighted edge
|
||||
if (!isFocusModeRef.current || !edge.hasClass("connection")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHoveredLink(null);
|
||||
setLinkTooltipPos(null);
|
||||
});
|
||||
|
||||
// Prevent edge selection to avoid gray border on click
|
||||
cy.on("select", "edge", (evt: any) => {
|
||||
evt.target.unselect();
|
||||
});
|
||||
|
||||
// Double-click to focus on node and its connections
|
||||
cy.on("dblclick", "node", (evt: any) => {
|
||||
const focusedNode = evt.target as cytoscape.NodeSingular;
|
||||
const focusedNodeId = focusedNode.id();
|
||||
|
||||
console.log("Double-clicked node:", focusedNodeId);
|
||||
|
||||
// Enter focus mode
|
||||
setIsFocusMode(true);
|
||||
|
||||
// Clear any existing focus classes
|
||||
cy.elements().removeClass("dimmed focused connected connection");
|
||||
|
||||
// Get all connected nodes and edges
|
||||
const connectedElements = focusedNode.neighborhood();
|
||||
const connectedNodes = connectedElements.nodes();
|
||||
const connectedEdges = connectedElements.edges();
|
||||
|
||||
// Apply styling classes
|
||||
cy.elements().addClass("dimmed"); // Dim everything first
|
||||
focusedNode.removeClass("dimmed").addClass("focused"); // Highlight the focused node
|
||||
connectedNodes.removeClass("dimmed").addClass("connected"); // Highlight connected nodes
|
||||
connectedEdges.removeClass("dimmed").addClass("connection"); // Highlight connecting edges
|
||||
|
||||
// Create a collection of all relevant elements for positioning
|
||||
const relevantElements = focusedNode.union(connectedElements);
|
||||
|
||||
// Reorient the graph to focus on this subgraph
|
||||
cy.animate(
|
||||
{
|
||||
fit: {
|
||||
eles: relevantElements,
|
||||
padding: 100,
|
||||
},
|
||||
center: {
|
||||
eles: focusedNode,
|
||||
},
|
||||
},
|
||||
{
|
||||
duration: 800,
|
||||
easing: "ease-out-cubic",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Click on background to reset focus
|
||||
cy.on("tap", (evt: any) => {
|
||||
if (evt.target === cy) {
|
||||
console.log("Clicked background - resetting focus");
|
||||
|
||||
// Exit focus mode
|
||||
setIsFocusMode(false);
|
||||
|
||||
// Remove all focus classes
|
||||
cy.elements().removeClass("dimmed focused connected connection");
|
||||
|
||||
// Zoom out to show all elements
|
||||
cy.animate(
|
||||
{
|
||||
fit: {
|
||||
eles: cy.elements(),
|
||||
padding: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
duration: 600,
|
||||
easing: "ease-out",
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
isInitializingRef.current = false;
|
||||
lastDataSignatureRef.current = dataSignature;
|
||||
} catch (error) {
|
||||
console.error("Error initializing cytoscape:", error);
|
||||
setIsLoading(false);
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
}, 100); // 100ms delay
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
clearTimeout(timeout);
|
||||
isInitializingRef.current = false;
|
||||
if (cyRef.current) {
|
||||
cyRef.current.destroy();
|
||||
cyRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [dataSignature, isMounted, containerDiv]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (cyRef.current) {
|
||||
cyRef.current.resize();
|
||||
cyRef.current.fit(undefined, 80);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full rounded-lg overflow-hidden border border-border"
|
||||
style={{ height }}
|
||||
>
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background z-10">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cytoscape container */}
|
||||
{isMounted && (
|
||||
<div
|
||||
ref={setContainerDiv}
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: isDarkMode
|
||||
? "radial-gradient(circle at 1px 1px, rgba(255,255,255,0.08) 1px, transparent 0)"
|
||||
: "radial-gradient(circle at 1px 1px, rgba(0,0,0,0.06) 1px, transparent 0)",
|
||||
backgroundSize: "20px 20px",
|
||||
backgroundColor: isDarkMode ? "#0f1419" : "#f8fafc",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && graphData.nodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground">{t("emptyState")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link hover tooltip */}
|
||||
{hoveredLink && linkTooltipPos && (
|
||||
<div
|
||||
className="absolute z-30 pointer-events-none"
|
||||
style={{
|
||||
left: linkTooltipPos.x,
|
||||
top: linkTooltipPos.y,
|
||||
transform: "translate(-50%, -100%) translateY(-8px)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`px-3 py-2 rounded-lg shadow-lg text-sm ${
|
||||
isDarkMode
|
||||
? "bg-gray-800 text-white"
|
||||
: "bg-white text-gray-900 border border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium capitalize mb-1">
|
||||
{(() => {
|
||||
const type = hoveredLink.type || "semantic";
|
||||
if (["causes", "caused_by", "enables", "prevents"].includes(type)) {
|
||||
return t("linkTypeCausal", { type: type.replace("_", " ") });
|
||||
}
|
||||
return t("linkTypeGeneric", { type });
|
||||
})()}
|
||||
</div>
|
||||
{hoveredLink.entity && (
|
||||
<div className="text-xs opacity-80">
|
||||
{t("linkTooltipEntity")} <span className="font-medium">{hoveredLink.entity}</span>
|
||||
</div>
|
||||
)}
|
||||
{hoveredLink.weight !== undefined && (
|
||||
<div className="text-xs opacity-80">
|
||||
{t("linkTooltipWeight")}{" "}
|
||||
<span className="font-medium">{hoveredLink.weight.toFixed(3)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls hint */}
|
||||
<div className="absolute bottom-4 right-4 text-xs text-muted-foreground/60 z-20">
|
||||
{t("controlsHint")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
export function convertHindsightGraphData(hindsightData: {
|
||||
nodes?: Array<{ data: { id: string; label?: string; color?: string } }>;
|
||||
edges?: Array<{
|
||||
data: {
|
||||
source: string;
|
||||
target: string;
|
||||
color?: string;
|
||||
lineStyle?: string;
|
||||
linkType?: string;
|
||||
entityName?: string;
|
||||
weight?: number;
|
||||
similarity?: number;
|
||||
};
|
||||
}>;
|
||||
table_rows?: Array<{ id: string; text: string; entities?: string; context?: string }>;
|
||||
}): GraphData {
|
||||
const nodes: GraphNode[] = (hindsightData.nodes || []).map((n) => {
|
||||
const tableRow = hindsightData.table_rows?.find((r) => r.id === n.data.id);
|
||||
// Use memory text as label, truncated to ~40 chars
|
||||
let label = n.data.label;
|
||||
if (!label && tableRow?.text) {
|
||||
label = tableRow.text.length > 40 ? tableRow.text.substring(0, 40) + "..." : tableRow.text;
|
||||
}
|
||||
if (!label) {
|
||||
label = n.data.id.substring(0, 8);
|
||||
}
|
||||
return {
|
||||
id: n.data.id,
|
||||
label,
|
||||
color: n.data.color,
|
||||
metadata: tableRow,
|
||||
};
|
||||
});
|
||||
|
||||
const links: GraphLink[] = (hindsightData.edges || []).map((e) => ({
|
||||
source: e.data.source,
|
||||
target: e.data.target,
|
||||
color: e.data.color,
|
||||
// Use linkType directly from API, fallback to lineStyle check, default to semantic
|
||||
type: e.data.linkType || (e.data.lineStyle === "dashed" ? "temporal" : "semantic"),
|
||||
entity: e.data.entityName, // API returns entityName
|
||||
weight: e.data.weight ?? e.data.similarity,
|
||||
}));
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Shared graph data model + conversion used by the memory visualizations
|
||||
// (Constellation, entities view). The Cytoscape-based "Graph" view that used to
|
||||
// live here was removed; only the framework-agnostic types and the API-response
|
||||
// converter remain, since the constellation and entity views build on them.
|
||||
|
||||
// ============================================================================
|
||||
// Types & Interfaces
|
||||
// ============================================================================
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
group?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GraphLink {
|
||||
source: string;
|
||||
target: string;
|
||||
color?: string;
|
||||
width?: number;
|
||||
type?: string;
|
||||
entity?: string;
|
||||
weight?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
export function convertHindsightGraphData(hindsightData: {
|
||||
nodes?: Array<{ data: { id: string; label?: string; color?: string } }>;
|
||||
edges?: Array<{
|
||||
data: {
|
||||
source: string;
|
||||
target: string;
|
||||
color?: string;
|
||||
lineStyle?: string;
|
||||
linkType?: string;
|
||||
entityName?: string;
|
||||
weight?: number;
|
||||
similarity?: number;
|
||||
};
|
||||
}>;
|
||||
table_rows?: Array<{ id: string; text: string; entities?: string; context?: string }>;
|
||||
}): GraphData {
|
||||
const nodes: GraphNode[] = (hindsightData.nodes || []).map((n) => {
|
||||
const tableRow = hindsightData.table_rows?.find((r) => r.id === n.data.id);
|
||||
// Use memory text as label, truncated to ~40 chars
|
||||
let label = n.data.label;
|
||||
if (!label && tableRow?.text) {
|
||||
label = tableRow.text.length > 40 ? tableRow.text.substring(0, 40) + "..." : tableRow.text;
|
||||
}
|
||||
if (!label) {
|
||||
label = n.data.id.substring(0, 8);
|
||||
}
|
||||
return {
|
||||
id: n.data.id,
|
||||
label,
|
||||
color: n.data.color,
|
||||
metadata: tableRow,
|
||||
};
|
||||
});
|
||||
|
||||
const links: GraphLink[] = (hindsightData.edges || []).map((e) => ({
|
||||
source: e.data.source,
|
||||
target: e.data.target,
|
||||
color: e.data.color,
|
||||
// Use linkType directly from API, fallback to lineStyle check, default to semantic
|
||||
type: e.data.linkType || (e.data.lineStyle === "dashed" ? "temporal" : "semantic"),
|
||||
entity: e.data.entityName, // API returns entityName
|
||||
weight: e.data.weight ?? e.data.similarity,
|
||||
}));
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
@@ -1,684 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { client, type KnowledgeNode } from "@/lib/api";
|
||||
import { useBank } from "@/lib/bank-context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FilePlus,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Loader2,
|
||||
Network,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { formatAbsoluteDateTime, formatRelativeTime } from "@/lib/relative-time";
|
||||
import { CompactMarkdown } from "./compact-markdown";
|
||||
import { Constellation } from "./constellation";
|
||||
import type { GraphData, GraphLink, GraphNode } from "./graph-2d";
|
||||
|
||||
type ViewMode = "tree" | "graph";
|
||||
type GraphResponse = Awaited<ReturnType<typeof client.getKnowledgeBaseGraph>>;
|
||||
type PageDetail = Awaited<ReturnType<typeof client.getKnowledgePage>>;
|
||||
|
||||
const FALLBACK_COLOR = "#0074d9";
|
||||
|
||||
function flatten(nodes: KnowledgeNode[], out: KnowledgeNode[] = []): KnowledgeNode[] {
|
||||
for (const n of nodes) {
|
||||
out.push(n);
|
||||
if (n.children?.length) flatten(n.children, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function KnowledgeBaseView() {
|
||||
const t = useTranslations("knowledgeBase");
|
||||
const { currentBank } = useBank();
|
||||
|
||||
const [roots, setRoots] = useState<KnowledgeNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [view, setView] = useState<ViewMode>("tree");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const [selected, setSelected] = useState<PageDetail | null>(null);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
|
||||
const [graph, setGraph] = useState<GraphResponse | null>(null);
|
||||
const [graphLoading, setGraphLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const [createKind, setCreateKind] = useState<"folder" | "page" | null>(null);
|
||||
const [form, setForm] = useState({ name: "", sourceQuery: "", parentId: "" });
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<KnowledgeNode | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
if (!currentBank) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await client.getKnowledgeTree(currentBank);
|
||||
setRoots(result.roots || []);
|
||||
} catch {
|
||||
// toast handled by interceptor
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentBank]);
|
||||
|
||||
const loadGraph = useCallback(async () => {
|
||||
if (!currentBank) return;
|
||||
setGraphLoading(true);
|
||||
try {
|
||||
setGraph(await client.getKnowledgeBaseGraph(currentBank));
|
||||
} catch {
|
||||
// toast handled by interceptor
|
||||
} finally {
|
||||
setGraphLoading(false);
|
||||
}
|
||||
}, [currentBank]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentBank) {
|
||||
setSelected(null);
|
||||
setGraph(null);
|
||||
loadTree();
|
||||
}
|
||||
}, [currentBank, loadTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "graph" && currentBank && !graph && !graphLoading) loadGraph();
|
||||
}, [view, currentBank, graph, graphLoading, loadGraph]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setSelected(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const allNodes = useMemo(() => flatten(roots), [roots]);
|
||||
const folders = useMemo(() => allNodes.filter((n) => n.kind === "folder"), [allNodes]);
|
||||
const folderCount = folders.length;
|
||||
const pageCount = allNodes.length - folderCount;
|
||||
|
||||
const openPage = useCallback(
|
||||
async (pageId: string) => {
|
||||
if (!currentBank) return;
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
setSelected(await client.getKnowledgePage(currentBank, pageId));
|
||||
} catch {
|
||||
// toast handled by interceptor
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
},
|
||||
[currentBank]
|
||||
);
|
||||
|
||||
const toggleFolder = useCallback((id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openCreate = (kind: "folder" | "page", parentId = "") => {
|
||||
setForm({ name: "", sourceQuery: "", parentId });
|
||||
setCreateKind(kind);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!currentBank || !createKind || !form.name.trim()) return;
|
||||
if (createKind === "page" && !form.sourceQuery.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const parent_id = form.parentId || null;
|
||||
if (createKind === "folder") {
|
||||
await client.createKnowledgeFolder(currentBank, {
|
||||
name: form.name.trim(),
|
||||
parent_id,
|
||||
});
|
||||
} else {
|
||||
await client.createKnowledgePage(currentBank, {
|
||||
name: form.name.trim(),
|
||||
source_query: form.sourceQuery.trim(),
|
||||
parent_id,
|
||||
});
|
||||
}
|
||||
if (parent_id) setExpanded((prev) => new Set(prev).add(parent_id));
|
||||
setCreateKind(null);
|
||||
await loadTree();
|
||||
setGraph(null);
|
||||
} catch {
|
||||
// toast handled by interceptor
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!currentBank || !deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await client.deleteKnowledgeNode(currentBank, deleteTarget.id);
|
||||
if (selected?.id === deleteTarget.id) setSelected(null);
|
||||
setDeleteTarget(null);
|
||||
await loadTree();
|
||||
setGraph(null);
|
||||
} catch {
|
||||
// toast handled by interceptor
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!currentBank) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const bundle = await client.exportKnowledgeBase(currentBank);
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${currentBank}-okf.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// toast handled by interceptor
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Constellation (graph view) — clustered by parent folder ───────────────
|
||||
const typeColors = useMemo(() => {
|
||||
const colors = new Map<string, string>();
|
||||
for (const n of graph?.nodes ?? []) colors.set(n.data.type, n.data.color);
|
||||
return colors;
|
||||
}, [graph]);
|
||||
|
||||
const constellationData = useMemo<GraphData>(() => {
|
||||
if (!graph) return { nodes: [], links: [] };
|
||||
const nodes: GraphNode[] = graph.nodes.map((n) => ({
|
||||
id: n.data.id,
|
||||
label: n.data.label,
|
||||
color: n.data.color,
|
||||
group: n.data.type,
|
||||
}));
|
||||
const links: GraphLink[] = graph.edges.map((e) => ({
|
||||
source: e.data.source,
|
||||
target: e.data.target,
|
||||
color: e.data.color,
|
||||
weight: e.data.weight,
|
||||
}));
|
||||
return { nodes, links };
|
||||
}, [graph]);
|
||||
|
||||
const nodeWeights = useMemo(() => {
|
||||
const weights = new Map<string, number>();
|
||||
for (const link of constellationData.links) {
|
||||
const w = typeof link.weight === "number" && link.weight > 0 ? link.weight : 1;
|
||||
weights.set(link.source, (weights.get(link.source) || 0) + w);
|
||||
weights.set(link.target, (weights.get(link.target) || 0) + w);
|
||||
}
|
||||
return weights;
|
||||
}, [constellationData]);
|
||||
|
||||
const maxNodeWeight = useMemo(() => {
|
||||
let max = 1;
|
||||
for (const w of nodeWeights.values()) if (w > max) max = w;
|
||||
return max;
|
||||
}, [nodeWeights]);
|
||||
|
||||
const nodeSizeFn = useCallback(
|
||||
(node: GraphNode) => 4 + Math.sqrt((nodeWeights.get(node.id) || 0) / maxNodeWeight) * 10,
|
||||
[nodeWeights, maxNodeWeight]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">{t("title")}</h1>
|
||||
<p className="text-muted-foreground mt-1">{t("description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => openCreate("folder")}>
|
||||
<FolderPlus className="w-4 h-4 mr-2" />
|
||||
{t("newFolder")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => openCreate("page")}>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
{t("newPage")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={exporting || !pageCount}
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{t("exportButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{view === "graph"
|
||||
? t("graphCount", { pages: graph?.total_pages ?? 0, links: graph?.total_edges ?? 0 })
|
||||
: t("count", { folders: folderCount, pages: pageCount })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setView("tree")}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||||
view === "tree"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Folder className="w-4 h-4" />
|
||||
{t("viewTree")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("graph")}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||||
view === "graph"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Network className="w-4 h-4" />
|
||||
{t("viewGraph")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "tree" ? (
|
||||
<div className="border border-border rounded-lg overflow-hidden min-h-[480px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-7 h-7 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
) : roots.length > 0 ? (
|
||||
<ul className="py-2">
|
||||
{roots.map((node) => (
|
||||
<TreeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
selectedId={selected?.id ?? null}
|
||||
onToggle={toggleFolder}
|
||||
onOpenPage={openPage}
|
||||
onAddChild={openCreate}
|
||||
onDelete={setDeleteTarget}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="text-center max-w-md px-6">
|
||||
<FolderOpen className="w-8 h-8 mx-auto mb-3 text-muted-foreground opacity-60" />
|
||||
<div className="text-sm text-muted-foreground">{t("empty")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{t("emptyHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
{graphLoading ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-7 h-7 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
) : constellationData.nodes.length > 0 ? (
|
||||
<Constellation
|
||||
data={constellationData}
|
||||
height={620}
|
||||
onNodeClick={(node) => openPage(node.id)}
|
||||
nodeSizeFn={nodeSizeFn}
|
||||
clusterKeyFn={(node) => node.group ?? null}
|
||||
clusterColorFn={(key) => typeColors.get(key) || FALLBACK_COLOR}
|
||||
clusterLabelFn={(key) => key}
|
||||
sizeLegendLabel={t("sizeLegendLabel")}
|
||||
compactLabels
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="text-sm text-muted-foreground">{t("empty")}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Page detail panel */}
|
||||
{(selected || loadingDetail) && (
|
||||
<div className="fixed right-0 top-0 h-screen w-[460px] bg-card border-l-2 border-primary shadow-2xl z-50 overflow-y-auto animate-in slide-in-from-right duration-300 ease-out">
|
||||
<div className="p-5">
|
||||
<div className="flex justify-between items-start mb-4 pb-4 border-b border-border">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-xl font-bold text-card-foreground truncate">
|
||||
{selected?.name ?? t("loadingPage")}
|
||||
</h3>
|
||||
{selected?.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 italic">
|
||||
“{selected.description}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelected(null)}
|
||||
className="h-8 w-8 p-0 flex-shrink-0"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{loadingDetail && !selected ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-6 h-6 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
) : selected ? (
|
||||
<div className="space-y-4">
|
||||
{selected.tags.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs">
|
||||
{selected.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selected.timestamp && (
|
||||
<div
|
||||
className="text-xs text-muted-foreground"
|
||||
title={formatAbsoluteDateTime(selected.timestamp)}
|
||||
>
|
||||
{t("updatedLabel")} {formatRelativeTime(selected.timestamp)}
|
||||
</div>
|
||||
)}
|
||||
{selected.body ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none border-t border-border pt-4">
|
||||
<CompactMarkdown>{selected.body}</CompactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic border-t border-border pt-4">
|
||||
{t("noBody")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create folder/page dialog */}
|
||||
<Dialog open={createKind !== null} onOpenChange={(o) => !o && setCreateKind(null)}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{createKind === "folder" ? t("createFolderTitle") : t("createPageTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">{t("fieldName")}</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{createKind === "page" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t("fieldSourceQuery")}
|
||||
</label>
|
||||
<Textarea
|
||||
value={form.sourceQuery}
|
||||
onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">{t("fieldParent")}</label>
|
||||
<Select
|
||||
value={form.parentId || "__root__"}
|
||||
onValueChange={(v) => setForm({ ...form, parentId: v === "__root__" ? "" : v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__root__">{t("rootFolder")}</SelectItem>
|
||||
{folders.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateKind(null)} disabled={creating}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={
|
||||
creating || !form.name.trim() || (createKind === "page" && !form.sourceQuery.trim())
|
||||
}
|
||||
>
|
||||
{creating ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
|
||||
{t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteButton")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteConfirm", { name: deleteTarget?.name ?? "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-row justify-end space-x-2">
|
||||
<AlertDialogCancel className="mt-0">{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
|
||||
{t("deleteButton")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeRow({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
selectedId,
|
||||
onToggle,
|
||||
onOpenPage,
|
||||
onAddChild,
|
||||
onDelete,
|
||||
t,
|
||||
}: {
|
||||
node: KnowledgeNode;
|
||||
depth: number;
|
||||
expanded: Set<string>;
|
||||
selectedId: string | null;
|
||||
onToggle: (id: string) => void;
|
||||
onOpenPage: (id: string) => void;
|
||||
onAddChild: (kind: "folder" | "page", parentId: string) => void;
|
||||
onDelete: (node: KnowledgeNode) => void;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
const isFolder = node.kind === "folder";
|
||||
const isOpen = expanded.has(node.id);
|
||||
const isActive = selectedId === node.id;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div
|
||||
className={`group flex items-center gap-1.5 pr-2 py-1.5 cursor-pointer border-l-2 transition-colors ${
|
||||
isActive
|
||||
? "bg-primary/10 border-primary text-foreground"
|
||||
: "border-transparent hover:bg-muted text-foreground"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 18 + 10}px` }}
|
||||
onClick={() => (isFolder ? onToggle(node.id) : onOpenPage(node.id))}
|
||||
>
|
||||
{isFolder ? (
|
||||
<>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{isOpen ? (
|
||||
<FolderOpen className="w-4 h-4 flex-shrink-0 text-amber-500" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 flex-shrink-0 text-amber-500" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-3.5 flex-shrink-0" />
|
||||
<FileText className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm truncate">{node.name}</span>
|
||||
{!isFolder && node.managed && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400 flex-shrink-0">
|
||||
{t("autoBadge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
{isFolder && (
|
||||
<>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
|
||||
title={t("newFolder")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddChild("folder", node.id);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
|
||||
title={t("newPage")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddChild("page", node.id);
|
||||
}}
|
||||
>
|
||||
<FilePlus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-red-600"
|
||||
title={t("deleteButton")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(node);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isFolder && isOpen && node.children?.length > 0 && (
|
||||
<ul>
|
||||
{node.children.map((child) => (
|
||||
<TreeRow
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
selectedId={selectedId}
|
||||
onToggle={onToggle}
|
||||
onOpenPage={onOpenPage}
|
||||
onAddChild={onAddChild}
|
||||
onDelete={onDelete}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -154,12 +154,23 @@ export function MentalModelsView() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const mentalModelsData = await client.listMentalModels(
|
||||
currentBank,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
selectedTags.length > 0 ? tagsMatch : undefined
|
||||
);
|
||||
setMentalModels(mentalModelsData.items || []);
|
||||
// The API caps each response at PAGE_SIZE, so page through until a short
|
||||
// page is returned to load every mental model for this bank.
|
||||
const PAGE_SIZE = 100;
|
||||
const all: MentalModel[] = [];
|
||||
for (let offset = 0; ; offset += PAGE_SIZE) {
|
||||
const page = await client.listMentalModels(
|
||||
currentBank,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
selectedTags.length > 0 ? tagsMatch : undefined,
|
||||
PAGE_SIZE,
|
||||
offset
|
||||
);
|
||||
const items = page.items || [];
|
||||
all.push(...items);
|
||||
if (items.length < PAGE_SIZE) break;
|
||||
}
|
||||
setMentalModels(all);
|
||||
} catch (error) {
|
||||
console.error("Error loading mental models:", error);
|
||||
} finally {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Database,
|
||||
FileText,
|
||||
Users,
|
||||
Network,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Settings,
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
|
||||
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "knowledge" | "profile";
|
||||
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile";
|
||||
|
||||
interface SidebarProps {
|
||||
currentTab: NavItem;
|
||||
@@ -36,7 +35,6 @@ export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ id: "knowledge" as NavItem, label: t("knowledge"), icon: Network },
|
||||
{ id: "data" as NavItem, label: t("memories"), icon: Database },
|
||||
{ id: "recall" as NavItem, label: t("recall"), icon: Search },
|
||||
{ id: "reflect" as NavItem, label: t("reflect"), icon: Sparkles },
|
||||
|
||||
@@ -14,7 +14,19 @@ interface JsonViewerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function parseJsonString(value: string): unknown {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function toDisplayText(value: unknown): string {
|
||||
value = typeof value === "string" ? parseJsonString(value) : value;
|
||||
if (typeof value === "string") return value;
|
||||
// Unescape newlines inside string values so multi-line content (e.g. prompts)
|
||||
// renders as real line breaks under `whitespace-pre-wrap` instead of literal "\n".
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary/50 border border-border">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
@@ -38,19 +38,6 @@ export interface WebhookHttpConfig {
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface KnowledgeNode {
|
||||
id: string;
|
||||
kind: "folder" | "page";
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
mental_model_id: string | null;
|
||||
managed: boolean;
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
timestamp: string | null;
|
||||
children: KnowledgeNode[];
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
id: string;
|
||||
bank_id: string | null;
|
||||
@@ -606,115 +593,6 @@ export class ControlPlaneClient {
|
||||
}>(`/api/entities/graph?${queryParams}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the knowledge base as a nested folder/page tree.
|
||||
*/
|
||||
async getKnowledgeTree(bankId: string) {
|
||||
return this.fetchApi<{ roots: KnowledgeNode[] }>(
|
||||
`/api/knowledge-base/tree?bank_id=${encodeURIComponent(bankId)}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the knowledge-base constellation graph (pages linked by shared tags).
|
||||
*/
|
||||
async getKnowledgeBaseGraph(bankId: string) {
|
||||
return this.fetchApi<{
|
||||
nodes: Array<{
|
||||
data: { id: string; label: string; type: string; tagCount: number; color: string };
|
||||
}>;
|
||||
edges: Array<{
|
||||
data: {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
sharedTags: string[];
|
||||
weight: number;
|
||||
color: string;
|
||||
};
|
||||
}>;
|
||||
total_pages: number;
|
||||
total_edges: number;
|
||||
}>(`/api/knowledge-base/graph?bank_id=${encodeURIComponent(bankId)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single knowledge page rendered as an OKF document.
|
||||
*/
|
||||
async getKnowledgePage(bankId: string, pageId: string) {
|
||||
return this.fetchApi<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
timestamp: string | null;
|
||||
body: string | null;
|
||||
markdown: string;
|
||||
}>(
|
||||
`/api/knowledge-base/pages/${encodeURIComponent(pageId)}?bank_id=${encodeURIComponent(bankId)}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a folder, optionally under a parent folder.
|
||||
*/
|
||||
async createKnowledgeFolder(
|
||||
bankId: string,
|
||||
body: { name: string; parent_id?: string | null }
|
||||
) {
|
||||
return this.fetchApi<KnowledgeNode>(
|
||||
`/api/knowledge-base/folders?bank_id=${encodeURIComponent(bankId)}`,
|
||||
{ method: "POST", body: JSON.stringify(body) }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a page (mental model + tree node). Content is generated asynchronously.
|
||||
*/
|
||||
async createKnowledgePage(
|
||||
bankId: string,
|
||||
body: { name: string; source_query: string; parent_id?: string | null; tags?: string[] }
|
||||
) {
|
||||
return this.fetchApi<{ page_id: string; mental_model_id: string; operation_id: string | null }>(
|
||||
`/api/knowledge-base/pages?bank_id=${encodeURIComponent(bankId)}`,
|
||||
{ method: "POST", body: JSON.stringify(body) }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename and/or move a node. Pass `parent_id: null` to move to the root.
|
||||
*/
|
||||
async updateKnowledgeNode(
|
||||
bankId: string,
|
||||
nodeId: string,
|
||||
body: { name?: string; parent_id?: string | null }
|
||||
) {
|
||||
return this.fetchApi<KnowledgeNode>(
|
||||
`/api/knowledge-base/nodes/${encodeURIComponent(nodeId)}?bank_id=${encodeURIComponent(bankId)}`,
|
||||
{ method: "PATCH", body: JSON.stringify(body) }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a node and its whole subtree.
|
||||
*/
|
||||
async deleteKnowledgeNode(bankId: string, nodeId: string) {
|
||||
return this.fetchApi<{ status: string }>(
|
||||
`/api/knowledge-base/nodes/${encodeURIComponent(nodeId)}?bank_id=${encodeURIComponent(bankId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the knowledge base as a portable OKF bundle (markdown files).
|
||||
*/
|
||||
async exportKnowledgeBase(bankId: string) {
|
||||
return this.fetchApi<{ files: Array<{ path: string; content: string }> }>(
|
||||
`/api/knowledge-base/export?bank_id=${encodeURIComponent(bankId)}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity details
|
||||
*/
|
||||
@@ -1327,7 +1205,13 @@ export class ControlPlaneClient {
|
||||
/**
|
||||
* List mental models for a bank
|
||||
*/
|
||||
async listMentalModels(bankId: string, tags?: string[], tagsMatch?: string) {
|
||||
async listMentalModels(
|
||||
bankId: string,
|
||||
tags?: string[],
|
||||
tagsMatch?: string,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (tags && tags.length > 0) {
|
||||
tags.forEach((t) => params.append("tags", t));
|
||||
@@ -1335,6 +1219,12 @@ export class ControlPlaneClient {
|
||||
if (tagsMatch) {
|
||||
params.append("tags_match", tagsMatch);
|
||||
}
|
||||
if (limit !== undefined) {
|
||||
params.append("limit", String(limit));
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
params.append("offset", String(offset));
|
||||
}
|
||||
const query = params.toString();
|
||||
return this.fetchApi<{
|
||||
items: Array<{
|
||||
|
||||
@@ -147,8 +147,7 @@
|
||||
"memoryBank": "Speicherbank",
|
||||
"expandSidebar": "Seitenleiste ausklappen",
|
||||
"collapseSidebar": "Seitenleiste einklappen",
|
||||
"collapse": "Einklappen",
|
||||
"knowledge": "Knowledge base"
|
||||
"collapse": "Einklappen"
|
||||
},
|
||||
"overviewAndOperations": "Übersichtsstatistik und Hintergrundoperationen für diesen Speicherbank.",
|
||||
"operations": "Operationen",
|
||||
@@ -535,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "Alle Erinnerungen konsolidiert (zuletzt: {date})",
|
||||
"pendingConsolidation": "{count} Erinnerungen stehen zur Konsolidierung aus",
|
||||
"constellation": "Konstellation",
|
||||
"graph": "Graph",
|
||||
"table": "Tabelle",
|
||||
"timeline": "Zeitleiste",
|
||||
"hidePanel": "Bereich ausblenden",
|
||||
@@ -549,20 +547,6 @@
|
||||
"linkTypes": "Verknüpfungstypen",
|
||||
"nodes": "Knoten",
|
||||
"links": "Verknüpfungen",
|
||||
"graphTitle": "Graph",
|
||||
"linksWithCount": "Verknüpfungen ({count})",
|
||||
"clickToFilter": "· klicken zum Filtern",
|
||||
"semantic": "Semantisch",
|
||||
"temporal": "Zeitlich",
|
||||
"entity": "Entität",
|
||||
"causal": "Kausal",
|
||||
"displayTitle": "Anzeige",
|
||||
"showLabels": "Beschriftungen anzeigen",
|
||||
"performanceTitle": "Leistung",
|
||||
"maxNodes": "Maximale Knoten",
|
||||
"allLinksVisible": "Alle Verknüpfungen zwischen sichtbaren Knoten werden angezeigt.",
|
||||
"limitedTo50Nodes": "⚠️ Aus Leistungsgründen auf 50 Knoten begrenzt. Gesamt: {count}",
|
||||
"clickNodeForDetails": "Auf einen Knoten klicken, um Details anzuzeigen",
|
||||
"columnObservation": "Beobachtung",
|
||||
"columnMemory": "Erinnerung",
|
||||
"columnSources": "Quellen",
|
||||
@@ -1454,15 +1438,6 @@
|
||||
"factTypeObservation": "Beobachtung",
|
||||
"actionClearContent": "Inhalt löschen"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "Diagramm wird geladen...",
|
||||
"emptyState": "Keine Erinnerungen zum Anzeigen",
|
||||
"linkTypeCausal": "Kausal ({type})",
|
||||
"linkTypeGeneric": "{type}-Verknüpfung",
|
||||
"linkTooltipEntity": "Entität:",
|
||||
"linkTooltipWeight": "Gewicht:",
|
||||
"controlsHint": "Ziehen zum Verschieben • Scrollen zum Zoomen • Doppelklick auf Knoten zum Fokussieren • Klick auf den Hintergrund zum Zurücksetzen"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "Scrollen zum Zoomen · Ziehen zum Verschieben · Hover zum Erkunden · Klicken zum Auswählen",
|
||||
"hudStats": "{memories} Erinnerungen · {visible} sichtbar · {labels} Beschriftungen · {links} Verknüpfungen · Zoom {zoom}x",
|
||||
@@ -1713,55 +1688,7 @@
|
||||
"update": "Webhook konnte nicht aktualisiert werden",
|
||||
"delete": "Webhook konnte nicht gelöscht werden",
|
||||
"deliveries": "Webhook-Zustellungen konnten nicht abgerufen werden"
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"tree": "Failed to fetch knowledge base tree",
|
||||
"graph": "Failed to fetch knowledge base graph",
|
||||
"export": "Failed to export knowledge base",
|
||||
"createFolder": "Failed to create folder",
|
||||
"createPage": "Failed to create page",
|
||||
"fetchPage": "Failed to fetch knowledge page",
|
||||
"updateNode": "Failed to update node",
|
||||
"deleteNode": "Failed to delete node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"title": "Knowledge base",
|
||||
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
|
||||
"loading": "Loading knowledge base…",
|
||||
"loadingPage": "Loading page…",
|
||||
"empty": "No folders or pages yet.",
|
||||
"emptyHint": "Create a folder or a page to get started.",
|
||||
"viewTree": "Tree",
|
||||
"viewGraph": "Graph",
|
||||
"newFolder": "New folder",
|
||||
"newPage": "New page",
|
||||
"exportButton": "Export OKF bundle",
|
||||
"count": "{folders} folders · {pages} pages",
|
||||
"graphCount": "{pages} pages · {links} shared-tag links",
|
||||
"sizeLegendLabel": "shared-tag links",
|
||||
"updatedLabel": "Updated",
|
||||
"close": "Close",
|
||||
"noBody": "This page has no content yet.",
|
||||
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
|
||||
"deleteButton": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"creating": "Creating…",
|
||||
"fieldName": "Name",
|
||||
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
|
||||
"fieldParent": "Parent folder",
|
||||
"rootFolder": "Root",
|
||||
"selectPagePrompt": "Select a page to read it.",
|
||||
"createFolderTitle": "New folder",
|
||||
"createPageTitle": "New page",
|
||||
"fieldMission": "Mission (what this folder should collect)",
|
||||
"missionPlaceholder": "e.g. Track all incidents for the payments service",
|
||||
"editMissionTitle": "Edit folder mission",
|
||||
"editMission": "Edit mission",
|
||||
"saveMission": "Save mission",
|
||||
"autoBadge": "auto",
|
||||
"missionNone": "No mission set"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,8 +148,7 @@
|
||||
"memoryBank": "Memory Bank",
|
||||
"expandSidebar": "Expand sidebar",
|
||||
"collapseSidebar": "Collapse sidebar",
|
||||
"collapse": "Collapse",
|
||||
"knowledge": "Knowledge base"
|
||||
"collapse": "Collapse"
|
||||
},
|
||||
"overviewAndOperations": "Overview statistics and background operations for this memory bank.",
|
||||
"operations": "Operations",
|
||||
@@ -535,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "All memories consolidated (last: {date})",
|
||||
"pendingConsolidation": "{count} memories pending consolidation",
|
||||
"constellation": "Constellation",
|
||||
"graph": "Graph",
|
||||
"table": "Table",
|
||||
"timeline": "Timeline",
|
||||
"hidePanel": "Hide panel",
|
||||
@@ -549,20 +547,6 @@
|
||||
"linkTypes": "Link types",
|
||||
"nodes": "Nodes",
|
||||
"links": "Links",
|
||||
"graphTitle": "Graph",
|
||||
"linksWithCount": "Links ({count})",
|
||||
"clickToFilter": "· click to filter",
|
||||
"semantic": "Semantic",
|
||||
"temporal": "Temporal",
|
||||
"entity": "Entity",
|
||||
"causal": "Causal",
|
||||
"displayTitle": "Display",
|
||||
"showLabels": "Show labels",
|
||||
"performanceTitle": "Performance",
|
||||
"maxNodes": "Max nodes",
|
||||
"allLinksVisible": "All links between visible nodes are shown.",
|
||||
"limitedTo50Nodes": "⚠️ Limited to 50 nodes for performance. Total: {count}",
|
||||
"clickNodeForDetails": "Click a node to see details",
|
||||
"columnObservation": "Observation",
|
||||
"columnMemory": "Memory",
|
||||
"columnSources": "Sources",
|
||||
@@ -1454,15 +1438,6 @@
|
||||
"factTypeObservation": "observation",
|
||||
"actionClearContent": "Clear Content"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "Loading graph...",
|
||||
"emptyState": "No memories to display",
|
||||
"linkTypeCausal": "Causal ({type})",
|
||||
"linkTypeGeneric": "{type} link",
|
||||
"linkTooltipEntity": "Entity:",
|
||||
"linkTooltipWeight": "Weight:",
|
||||
"controlsHint": "Drag to pan • Scroll to zoom • Double-click node to focus • Click background to reset"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "Scroll to zoom · Drag to pan · Hover to explore · Click to select",
|
||||
"hudStats": "{memories} memories · {visible} visible · {labels} labels · {links} links · zoom {zoom}x",
|
||||
@@ -1713,55 +1688,7 @@
|
||||
"update": "Failed to update webhook",
|
||||
"delete": "Failed to delete webhook",
|
||||
"deliveries": "Failed to fetch webhook deliveries"
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"tree": "Failed to fetch knowledge base tree",
|
||||
"graph": "Failed to fetch knowledge base graph",
|
||||
"export": "Failed to export knowledge base",
|
||||
"createFolder": "Failed to create folder",
|
||||
"createPage": "Failed to create page",
|
||||
"fetchPage": "Failed to fetch knowledge page",
|
||||
"updateNode": "Failed to update node",
|
||||
"deleteNode": "Failed to delete node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"title": "Knowledge base",
|
||||
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
|
||||
"loading": "Loading knowledge base…",
|
||||
"loadingPage": "Loading page…",
|
||||
"empty": "No folders or pages yet.",
|
||||
"emptyHint": "Create a folder or a page to get started.",
|
||||
"viewTree": "Tree",
|
||||
"viewGraph": "Graph",
|
||||
"newFolder": "New folder",
|
||||
"newPage": "New page",
|
||||
"exportButton": "Export OKF bundle",
|
||||
"count": "{folders} folders · {pages} pages",
|
||||
"graphCount": "{pages} pages · {links} shared-tag links",
|
||||
"sizeLegendLabel": "shared-tag links",
|
||||
"updatedLabel": "Updated",
|
||||
"close": "Close",
|
||||
"noBody": "This page has no content yet.",
|
||||
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
|
||||
"deleteButton": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"creating": "Creating…",
|
||||
"fieldName": "Name",
|
||||
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
|
||||
"fieldParent": "Parent folder",
|
||||
"rootFolder": "Root",
|
||||
"selectPagePrompt": "Select a page to read it.",
|
||||
"createFolderTitle": "New folder",
|
||||
"createPageTitle": "New page",
|
||||
"fieldMission": "Mission (what this folder should collect)",
|
||||
"missionPlaceholder": "e.g. Track all incidents for the payments service",
|
||||
"editMissionTitle": "Edit folder mission",
|
||||
"editMission": "Edit mission",
|
||||
"saveMission": "Save mission",
|
||||
"autoBadge": "auto",
|
||||
"missionNone": "No mission set"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +147,7 @@
|
||||
"memoryBank": "Banco de memoria",
|
||||
"expandSidebar": "Expandir barra lateral",
|
||||
"collapseSidebar": "Contraer barra lateral",
|
||||
"collapse": "Contraer",
|
||||
"knowledge": "Knowledge base"
|
||||
"collapse": "Contraer"
|
||||
},
|
||||
"overviewAndOperations": "Estadísticas generales y operaciones en segundo plano de este banco de memoria.",
|
||||
"operations": "Operaciones",
|
||||
@@ -535,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "Todas las memorias consolidadas (última: {date})",
|
||||
"pendingConsolidation": "{count} memorias pendientes de consolidación",
|
||||
"constellation": "Constelación",
|
||||
"graph": "Grafo",
|
||||
"table": "Tabla",
|
||||
"timeline": "Línea de tiempo",
|
||||
"hidePanel": "Ocultar panel",
|
||||
@@ -549,20 +547,6 @@
|
||||
"linkTypes": "Tipos de vínculo",
|
||||
"nodes": "Nodos",
|
||||
"links": "Vínculos",
|
||||
"graphTitle": "Grafo",
|
||||
"linksWithCount": "Vínculos ({count})",
|
||||
"clickToFilter": "· clic para filtrar",
|
||||
"semantic": "Semántico",
|
||||
"temporal": "Temporal",
|
||||
"entity": "Entidad",
|
||||
"causal": "Causal",
|
||||
"displayTitle": "Visualización",
|
||||
"showLabels": "Mostrar etiquetas",
|
||||
"performanceTitle": "Rendimiento",
|
||||
"maxNodes": "Nodos máximos",
|
||||
"allLinksVisible": "Se muestran todos los vínculos entre nodos visibles.",
|
||||
"limitedTo50Nodes": "⚠️ Limitado a 50 nodos por rendimiento. Total: {count}",
|
||||
"clickNodeForDetails": "Haz clic en un nodo para ver detalles",
|
||||
"columnObservation": "Observación",
|
||||
"columnMemory": "Memoria",
|
||||
"columnSources": "Fuentes",
|
||||
@@ -1454,15 +1438,6 @@
|
||||
"factTypeObservation": "observación",
|
||||
"actionClearContent": "Borrar contenido"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "Cargando gráfico...",
|
||||
"emptyState": "No hay memorias que mostrar",
|
||||
"linkTypeCausal": "Causal ({type})",
|
||||
"linkTypeGeneric": "Enlace {type}",
|
||||
"linkTooltipEntity": "Entidad:",
|
||||
"linkTooltipWeight": "Peso:",
|
||||
"controlsHint": "Arrastra para mover • Desplaza para hacer zoom • Doble clic en un nodo para enfocar • Clic en el fondo para restablecer"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "Desplaza para hacer zoom · Arrastra para mover · Pasa el cursor para explorar · Haz clic para seleccionar",
|
||||
"hudStats": "{memories} memorias · {visible} visibles · {labels} etiquetas · {links} enlaces · zoom {zoom}x",
|
||||
@@ -1713,55 +1688,7 @@
|
||||
"update": "No se pudo actualizar el webhook",
|
||||
"delete": "No se pudo eliminar el webhook",
|
||||
"deliveries": "No se pudieron obtener las entregas del webhook"
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"tree": "Failed to fetch knowledge base tree",
|
||||
"graph": "Failed to fetch knowledge base graph",
|
||||
"export": "Failed to export knowledge base",
|
||||
"createFolder": "Failed to create folder",
|
||||
"createPage": "Failed to create page",
|
||||
"fetchPage": "Failed to fetch knowledge page",
|
||||
"updateNode": "Failed to update node",
|
||||
"deleteNode": "Failed to delete node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"title": "Knowledge base",
|
||||
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
|
||||
"loading": "Loading knowledge base…",
|
||||
"loadingPage": "Loading page…",
|
||||
"empty": "No folders or pages yet.",
|
||||
"emptyHint": "Create a folder or a page to get started.",
|
||||
"viewTree": "Tree",
|
||||
"viewGraph": "Graph",
|
||||
"newFolder": "New folder",
|
||||
"newPage": "New page",
|
||||
"exportButton": "Export OKF bundle",
|
||||
"count": "{folders} folders · {pages} pages",
|
||||
"graphCount": "{pages} pages · {links} shared-tag links",
|
||||
"sizeLegendLabel": "shared-tag links",
|
||||
"updatedLabel": "Updated",
|
||||
"close": "Close",
|
||||
"noBody": "This page has no content yet.",
|
||||
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
|
||||
"deleteButton": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"creating": "Creating…",
|
||||
"fieldName": "Name",
|
||||
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
|
||||
"fieldParent": "Parent folder",
|
||||
"rootFolder": "Root",
|
||||
"selectPagePrompt": "Select a page to read it.",
|
||||
"createFolderTitle": "New folder",
|
||||
"createPageTitle": "New page",
|
||||
"fieldMission": "Mission (what this folder should collect)",
|
||||
"missionPlaceholder": "e.g. Track all incidents for the payments service",
|
||||
"editMissionTitle": "Edit folder mission",
|
||||
"editMission": "Edit mission",
|
||||
"saveMission": "Save mission",
|
||||
"autoBadge": "auto",
|
||||
"missionNone": "No mission set"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +147,7 @@
|
||||
"memoryBank": "Banque de mémoire",
|
||||
"expandSidebar": "Développer la barre latérale",
|
||||
"collapseSidebar": "Réduire la barre latérale",
|
||||
"collapse": "Réduire",
|
||||
"knowledge": "Knowledge base"
|
||||
"collapse": "Réduire"
|
||||
},
|
||||
"overviewAndOperations": "Statistiques générales et opérations en arrière-plan pour cette banque mémoire.",
|
||||
"operations": "Opérations",
|
||||
@@ -535,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "Tous les souvenirs consolidés (dernier : {date})",
|
||||
"pendingConsolidation": "{count} souvenirs en attente de consolidation",
|
||||
"constellation": "Constellation",
|
||||
"graph": "Graphe",
|
||||
"table": "Tableau",
|
||||
"timeline": "Chronologie",
|
||||
"hidePanel": "Masquer le panneau",
|
||||
@@ -549,20 +547,6 @@
|
||||
"linkTypes": "Types de liens",
|
||||
"nodes": "Nœuds",
|
||||
"links": "Liens",
|
||||
"graphTitle": "Graphe",
|
||||
"linksWithCount": "Liens ({count})",
|
||||
"clickToFilter": "· cliquer pour filtrer",
|
||||
"semantic": "Sémantique",
|
||||
"temporal": "Temporel",
|
||||
"entity": "Entité",
|
||||
"causal": "Causal",
|
||||
"displayTitle": "Affichage",
|
||||
"showLabels": "Afficher les étiquettes",
|
||||
"performanceTitle": "Performance",
|
||||
"maxNodes": "Nœuds max",
|
||||
"allLinksVisible": "Tous les liens entre les nœuds visibles sont affichés.",
|
||||
"limitedTo50Nodes": "⚠️ Limité à 50 nœuds pour les performances. Total : {count}",
|
||||
"clickNodeForDetails": "Cliquez sur un nœud pour voir les détails",
|
||||
"columnObservation": "Observation",
|
||||
"columnMemory": "Souvenir",
|
||||
"columnSources": "Sources",
|
||||
@@ -1454,15 +1438,6 @@
|
||||
"factTypeObservation": "observation",
|
||||
"actionClearContent": "Effacer le contenu"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "Chargement du graphe...",
|
||||
"emptyState": "Aucun souvenir à afficher",
|
||||
"linkTypeCausal": "Causal ({type})",
|
||||
"linkTypeGeneric": "Lien {type}",
|
||||
"linkTooltipEntity": "Entité :",
|
||||
"linkTooltipWeight": "Poids :",
|
||||
"controlsHint": "Glisser pour déplacer • Défiler pour zoomer • Double-clic sur un nœud pour zoomer • Clic sur le fond pour réinitialiser"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "Défiler pour zoomer · Glisser pour déplacer · Survoler pour explorer · Cliquer pour sélectionner",
|
||||
"hudStats": "{memories} souvenirs · {visible} visibles · {labels} étiquettes · {links} liens · zoom {zoom}x",
|
||||
@@ -1713,55 +1688,7 @@
|
||||
"update": "Impossible de mettre à jour le webhook",
|
||||
"delete": "Impossible de supprimer le webhook",
|
||||
"deliveries": "Impossible de récupérer les livraisons du webhook"
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"tree": "Failed to fetch knowledge base tree",
|
||||
"graph": "Failed to fetch knowledge base graph",
|
||||
"export": "Failed to export knowledge base",
|
||||
"createFolder": "Failed to create folder",
|
||||
"createPage": "Failed to create page",
|
||||
"fetchPage": "Failed to fetch knowledge page",
|
||||
"updateNode": "Failed to update node",
|
||||
"deleteNode": "Failed to delete node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"title": "Knowledge base",
|
||||
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
|
||||
"loading": "Loading knowledge base…",
|
||||
"loadingPage": "Loading page…",
|
||||
"empty": "No folders or pages yet.",
|
||||
"emptyHint": "Create a folder or a page to get started.",
|
||||
"viewTree": "Tree",
|
||||
"viewGraph": "Graph",
|
||||
"newFolder": "New folder",
|
||||
"newPage": "New page",
|
||||
"exportButton": "Export OKF bundle",
|
||||
"count": "{folders} folders · {pages} pages",
|
||||
"graphCount": "{pages} pages · {links} shared-tag links",
|
||||
"sizeLegendLabel": "shared-tag links",
|
||||
"updatedLabel": "Updated",
|
||||
"close": "Close",
|
||||
"noBody": "This page has no content yet.",
|
||||
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
|
||||
"deleteButton": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"creating": "Creating…",
|
||||
"fieldName": "Name",
|
||||
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
|
||||
"fieldParent": "Parent folder",
|
||||
"rootFolder": "Root",
|
||||
"selectPagePrompt": "Select a page to read it.",
|
||||
"createFolderTitle": "New folder",
|
||||
"createPageTitle": "New page",
|
||||
"fieldMission": "Mission (what this folder should collect)",
|
||||
"missionPlaceholder": "e.g. Track all incidents for the payments service",
|
||||
"editMissionTitle": "Edit folder mission",
|
||||
"editMission": "Edit mission",
|
||||
"saveMission": "Save mission",
|
||||
"autoBadge": "auto",
|
||||
"missionNone": "No mission set"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +147,7 @@
|
||||
"memoryBank": "メモリバンク",
|
||||
"expandSidebar": "サイドバーを展開",
|
||||
"collapseSidebar": "サイドバーを折りたたむ",
|
||||
"collapse": "折りたたむ",
|
||||
"knowledge": "Knowledge base"
|
||||
"collapse": "折りたたむ"
|
||||
},
|
||||
"overviewAndOperations": "このメモリバンクの概要統計とバックグラウンド操作。",
|
||||
"operations": "操作",
|
||||
@@ -535,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "すべてのメモリが統合済み(最終:{date})",
|
||||
"pendingConsolidation": "{count}件のメモリが統合待ち",
|
||||
"constellation": "コンステレーション",
|
||||
"graph": "グラフ",
|
||||
"table": "テーブル",
|
||||
"timeline": "タイムライン",
|
||||
"hidePanel": "パネルを非表示",
|
||||
@@ -549,20 +547,6 @@
|
||||
"linkTypes": "リンクの種類",
|
||||
"nodes": "ノード",
|
||||
"links": "リンク",
|
||||
"graphTitle": "グラフ",
|
||||
"linksWithCount": "リンク({count}件)",
|
||||
"clickToFilter": "・クリックでフィルター",
|
||||
"semantic": "セマンティック",
|
||||
"temporal": "時系列",
|
||||
"entity": "エンティティ",
|
||||
"causal": "因果",
|
||||
"displayTitle": "表示",
|
||||
"showLabels": "ラベルを表示",
|
||||
"performanceTitle": "パフォーマンス",
|
||||
"maxNodes": "最大ノード数",
|
||||
"allLinksVisible": "表示中のノード間のすべてのリンクが表示されています。",
|
||||
"limitedTo50Nodes": "⚠️ パフォーマンスのため50ノードに制限されています。合計:{count}",
|
||||
"clickNodeForDetails": "ノードをクリックして詳細を確認",
|
||||
"columnObservation": "観察",
|
||||
"columnMemory": "メモリ",
|
||||
"columnSources": "ソース",
|
||||
@@ -1454,15 +1438,6 @@
|
||||
"factTypeObservation": "オブザベーション",
|
||||
"actionClearContent": "内容をクリア"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "グラフを読み込み中...",
|
||||
"emptyState": "表示するメモリがありません",
|
||||
"linkTypeCausal": "因果 ({type})",
|
||||
"linkTypeGeneric": "{type} リンク",
|
||||
"linkTooltipEntity": "エンティティ:",
|
||||
"linkTooltipWeight": "ウェイト:",
|
||||
"controlsHint": "ドラッグで移動 • スクロールでズーム • ノードをダブルクリックでフォーカス • 背景をクリックでリセット"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "スクロールでズーム · ドラッグで移動 · ホバーで探索 · クリックで選択",
|
||||
"hudStats": "{memories} 件のメモリ · {visible} 件表示 · {labels} 件のラベル · {links} 件のリンク · ズーム {zoom}x",
|
||||
@@ -1713,55 +1688,7 @@
|
||||
"update": "Webhookの更新に失敗しました",
|
||||
"delete": "Webhookの削除に失敗しました",
|
||||
"deliveries": "Webhook配信の取得に失敗しました"
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"tree": "Failed to fetch knowledge base tree",
|
||||
"graph": "Failed to fetch knowledge base graph",
|
||||
"export": "Failed to export knowledge base",
|
||||
"createFolder": "Failed to create folder",
|
||||
"createPage": "Failed to create page",
|
||||
"fetchPage": "Failed to fetch knowledge page",
|
||||
"updateNode": "Failed to update node",
|
||||
"deleteNode": "Failed to delete node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"title": "Knowledge base",
|
||||
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
|
||||
"loading": "Loading knowledge base…",
|
||||
"loadingPage": "Loading page…",
|
||||
"empty": "No folders or pages yet.",
|
||||
"emptyHint": "Create a folder or a page to get started.",
|
||||
"viewTree": "Tree",
|
||||
"viewGraph": "Graph",
|
||||
"newFolder": "New folder",
|
||||
"newPage": "New page",
|
||||
"exportButton": "Export OKF bundle",
|
||||
"count": "{folders} folders · {pages} pages",
|
||||
"graphCount": "{pages} pages · {links} shared-tag links",
|
||||
"sizeLegendLabel": "shared-tag links",
|
||||
"updatedLabel": "Updated",
|
||||
"close": "Close",
|
||||
"noBody": "This page has no content yet.",
|
||||
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
|
||||
"deleteButton": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"creating": "Creating…",
|
||||
"fieldName": "Name",
|
||||
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
|
||||
"fieldParent": "Parent folder",
|
||||
"rootFolder": "Root",
|
||||
"selectPagePrompt": "Select a page to read it.",
|
||||
"createFolderTitle": "New folder",
|
||||
"createPageTitle": "New page",
|
||||
"fieldMission": "Mission (what this folder should collect)",
|
||||
"missionPlaceholder": "e.g. Track all incidents for the payments service",
|
||||
"editMissionTitle": "Edit folder mission",
|
||||
"editMission": "Edit mission",
|
||||
"saveMission": "Save mission",
|
||||
"autoBadge": "auto",
|
||||
"missionNone": "No mission set"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +147,7 @@
|
||||
"memoryBank": "메모리 뱅크",
|
||||
"expandSidebar": "사이드바 펼치기",
|
||||
"collapseSidebar": "사이드바 접기",
|
||||
"collapse": "접기",
|
||||
"knowledge": "Knowledge base"
|
||||
"collapse": "접기"
|
||||
},
|
||||
"overviewAndOperations": "이 메모리 뱅크의 개요 통계 및 백그라운드 작업.",
|
||||
"operations": "작업",
|
||||
@@ -535,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "모든 메모리 통합됨 (마지막: {date})",
|
||||
"pendingConsolidation": "{count}개 메모리 통합 대기 중",
|
||||
"constellation": "별자리",
|
||||
"graph": "그래프",
|
||||
"table": "표",
|
||||
"timeline": "타임라인",
|
||||
"hidePanel": "패널 숨기기",
|
||||
@@ -549,20 +547,6 @@
|
||||
"linkTypes": "링크 유형",
|
||||
"nodes": "노드",
|
||||
"links": "링크",
|
||||
"graphTitle": "그래프",
|
||||
"linksWithCount": "링크 ({count})",
|
||||
"clickToFilter": "· 클릭하여 필터링",
|
||||
"semantic": "의미적",
|
||||
"temporal": "시간적",
|
||||
"entity": "엔티티",
|
||||
"causal": "인과적",
|
||||
"displayTitle": "표시",
|
||||
"showLabels": "레이블 표시",
|
||||
"performanceTitle": "성능",
|
||||
"maxNodes": "최대 노드",
|
||||
"allLinksVisible": "표시된 노드 간의 모든 링크가 표시됩니다.",
|
||||
"limitedTo50Nodes": "⚠️ 성능을 위해 50개 노드로 제한됩니다. 전체: {count}",
|
||||
"clickNodeForDetails": "노드를 클릭하면 세부 정보를 볼 수 있습니다",
|
||||
"columnObservation": "관찰",
|
||||
"columnMemory": "메모리",
|
||||
"columnSources": "소스",
|
||||
@@ -1454,15 +1438,6 @@
|
||||
"factTypeObservation": "관찰",
|
||||
"actionClearContent": "콘텐츠 지우기"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "그래프 로딩 중...",
|
||||
"emptyState": "표시할 메모리가 없습니다",
|
||||
"linkTypeCausal": "인과 ({type})",
|
||||
"linkTypeGeneric": "{type} 링크",
|
||||
"linkTooltipEntity": "엔티티:",
|
||||
"linkTooltipWeight": "가중치:",
|
||||
"controlsHint": "드래그하여 이동 • 스크롤하여 확대/축소 • 노드 더블클릭으로 포커스 • 배경 클릭으로 초기화"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "스크롤하여 확대/축소 · 드래그하여 이동 · 호버하여 탐색 · 클릭하여 선택",
|
||||
"hudStats": "{memories}개 메모리 · {visible}개 표시 · {labels}개 레이블 · {links}개 링크 · 줌 {zoom}x",
|
||||
@@ -1713,55 +1688,7 @@
|
||||
"update": "Webhook을 업데이트하지 못했습니다",
|
||||
"delete": "Webhook을 삭제하지 못했습니다",
|
||||
"deliveries": "Webhook 전달 내역을 가져오지 못했습니다"
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"tree": "Failed to fetch knowledge base tree",
|
||||
"graph": "Failed to fetch knowledge base graph",
|
||||
"export": "Failed to export knowledge base",
|
||||
"createFolder": "Failed to create folder",
|
||||
"createPage": "Failed to create page",
|
||||
"fetchPage": "Failed to fetch knowledge page",
|
||||
"updateNode": "Failed to update node",
|
||||
"deleteNode": "Failed to delete node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"title": "Knowledge base",
|
||||
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
|
||||
"loading": "Loading knowledge base…",
|
||||
"loadingPage": "Loading page…",
|
||||
"empty": "No folders or pages yet.",
|
||||
"emptyHint": "Create a folder or a page to get started.",
|
||||
"viewTree": "Tree",
|
||||
"viewGraph": "Graph",
|
||||
"newFolder": "New folder",
|
||||
"newPage": "New page",
|
||||
"exportButton": "Export OKF bundle",
|
||||
"count": "{folders} folders · {pages} pages",
|
||||
"graphCount": "{pages} pages · {links} shared-tag links",
|
||||
"sizeLegendLabel": "shared-tag links",
|
||||
"updatedLabel": "Updated",
|
||||
"close": "Close",
|
||||
"noBody": "This page has no content yet.",
|
||||
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
|
||||
"deleteButton": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"creating": "Creating…",
|
||||
"fieldName": "Name",
|
||||
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
|
||||
"fieldParent": "Parent folder",
|
||||
"rootFolder": "Root",
|
||||
"selectPagePrompt": "Select a page to read it.",
|
||||
"createFolderTitle": "New folder",
|
||||
"createPageTitle": "New page",
|
||||
"fieldMission": "Mission (what this folder should collect)",
|
||||
"missionPlaceholder": "e.g. Track all incidents for the payments service",
|
||||
"editMissionTitle": "Edit folder mission",
|
||||
"editMission": "Edit mission",
|
||||
"saveMission": "Save mission",
|
||||
"autoBadge": "auto",
|
||||
"missionNone": "No mission set"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +147,7 @@
|
||||
"memoryBank": "Banco de memória",
|
||||
"expandSidebar": "Expandir barra lateral",
|
||||
"collapseSidebar": "Recolher barra lateral",
|
||||
"collapse": "Recolher",
|
||||
"knowledge": "Knowledge base"
|
||||
"collapse": "Recolher"
|
||||
},
|
||||
"overviewAndOperations": "Estatísticas gerais e operações em segundo plano para este banco de memória.",
|
||||
"operations": "Operações",
|
||||
@@ -535,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "Todas as memórias consolidadas (última: {date})",
|
||||
"pendingConsolidation": "{count} memórias pendentes de consolidação",
|
||||
"constellation": "Constelação",
|
||||
"graph": "Grafo",
|
||||
"table": "Tabela",
|
||||
"timeline": "Linha do Tempo",
|
||||
"hidePanel": "Ocultar painel",
|
||||
@@ -549,20 +547,6 @@
|
||||
"linkTypes": "Tipos de vínculo",
|
||||
"nodes": "Nós",
|
||||
"links": "Vínculos",
|
||||
"graphTitle": "Grafo",
|
||||
"linksWithCount": "Vínculos ({count})",
|
||||
"clickToFilter": "· clique para filtrar",
|
||||
"semantic": "Semântico",
|
||||
"temporal": "Temporal",
|
||||
"entity": "Entidade",
|
||||
"causal": "Causal",
|
||||
"displayTitle": "Exibição",
|
||||
"showLabels": "Mostrar rótulos",
|
||||
"performanceTitle": "Desempenho",
|
||||
"maxNodes": "Máximo de nós",
|
||||
"allLinksVisible": "Todos os vínculos entre os nós visíveis estão sendo exibidos.",
|
||||
"limitedTo50Nodes": "⚠️ Limitado a 50 nós por desempenho. Total: {count}",
|
||||
"clickNodeForDetails": "Clique em um nó para ver detalhes",
|
||||
"columnObservation": "Observação",
|
||||
"columnMemory": "Memória",
|
||||
"columnSources": "Fontes",
|
||||
@@ -1454,15 +1438,6 @@
|
||||
"factTypeObservation": "observação",
|
||||
"actionClearContent": "Limpar conteúdo"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "Carregando grafo...",
|
||||
"emptyState": "Nenhuma memória para exibir",
|
||||
"linkTypeCausal": "Causal ({type})",
|
||||
"linkTypeGeneric": "Link {type}",
|
||||
"linkTooltipEntity": "Entidade:",
|
||||
"linkTooltipWeight": "Peso:",
|
||||
"controlsHint": "Arraste para mover • Scroll para zoom • Duplo clique no nó para focar • Clique no fundo para redefinir"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "Scroll para zoom · Arraste para mover · Passe o mouse para explorar · Clique para selecionar",
|
||||
"hudStats": "{memories} memórias · {visible} visíveis · {labels} rótulos · {links} links · zoom {zoom}x",
|
||||
@@ -1713,55 +1688,7 @@
|
||||
"update": "Falha ao atualizar webhook",
|
||||
"delete": "Falha ao excluir webhook",
|
||||
"deliveries": "Falha ao buscar entregas do webhook"
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"tree": "Failed to fetch knowledge base tree",
|
||||
"graph": "Failed to fetch knowledge base graph",
|
||||
"export": "Failed to export knowledge base",
|
||||
"createFolder": "Failed to create folder",
|
||||
"createPage": "Failed to create page",
|
||||
"fetchPage": "Failed to fetch knowledge page",
|
||||
"updateNode": "Failed to update node",
|
||||
"deleteNode": "Failed to delete node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledgeBase": {
|
||||
"title": "Knowledge base",
|
||||
"description": "Organize your knowledge into folders and pages, linked by shared tags.",
|
||||
"loading": "Loading knowledge base…",
|
||||
"loadingPage": "Loading page…",
|
||||
"empty": "No folders or pages yet.",
|
||||
"emptyHint": "Create a folder or a page to get started.",
|
||||
"viewTree": "Tree",
|
||||
"viewGraph": "Graph",
|
||||
"newFolder": "New folder",
|
||||
"newPage": "New page",
|
||||
"exportButton": "Export OKF bundle",
|
||||
"count": "{folders} folders · {pages} pages",
|
||||
"graphCount": "{pages} pages · {links} shared-tag links",
|
||||
"sizeLegendLabel": "shared-tag links",
|
||||
"updatedLabel": "Updated",
|
||||
"close": "Close",
|
||||
"noBody": "This page has no content yet.",
|
||||
"deleteConfirm": "Delete \"{name}\" and everything inside it?",
|
||||
"deleteButton": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"creating": "Creating…",
|
||||
"fieldName": "Name",
|
||||
"fieldSourceQuery": "Source query (the question that rebuilds this page)",
|
||||
"fieldParent": "Parent folder",
|
||||
"rootFolder": "Root",
|
||||
"selectPagePrompt": "Select a page to read it.",
|
||||
"createFolderTitle": "New folder",
|
||||
"createPageTitle": "New page",
|
||||
"fieldMission": "Mission (what this folder should collect)",
|
||||
"missionPlaceholder": "e.g. Track all incidents for the payments service",
|
||||
"editMissionTitle": "Edit folder mission",
|
||||
"editMission": "Edit mission",
|
||||
"saveMission": "Save mission",
|
||||
"autoBadge": "auto",
|
||||
"missionNone": "No mission set"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user