Adding Festinger with wordnet
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY festinger/ ./festinger/
|
||||
COPY db/ ./db/
|
||||
COPY wordnet/ ./wordnet/
|
||||
COPY config.yaml ./
|
||||
|
||||
EXPOSE 11434
|
||||
|
||||
CMD ["python", "-m", "uvicorn", "festinger.main:app", "--host", "0.0.0.0", "--port", "11434", "--log-level", "info"]
|
||||
@@ -0,0 +1,824 @@
|
||||
# Festinger — Agent0 Inference Middleware
|
||||
|
||||
**Status:** In progress — iterative specification
|
||||
**Owner:** jenstandstad
|
||||
**Location:** `plugins/festinger/`
|
||||
|
||||
> Named after Leon Festinger (1919–1989), social psychologist who introduced the theory of cognitive dissonance in 1957. Festinger observed that minds — human or artificial — cannot comfortably hold contradictory beliefs simultaneously, and that the tension this creates drives resolution. This system is built on the same principle.
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Festinger is an Ollama-compatible HTTP proxy that sits between Agent0's agent-zero containers and the local Ollama inference endpoint. It solves two related problems that emerge with local inference:
|
||||
|
||||
1. **Reasoning loops** — agents repeat the same output and cannot break out, even when the framework tells them to try something else.
|
||||
2. **Stale and incoherent memory** — Agent Zero's FAISS-based memory accumulates facts without contradiction detection, causing agents to act on outdated or conflicting beliefs.
|
||||
|
||||
Festinger addresses both at the inference layer, transparently, without modifying agent-zero internals. The memory layer it introduces is called **Recollections** — short, structured, non-contradicting facts injected spontaneously into every prompt as context enrichment. Agents do not search for recollections; they appear automatically.
|
||||
|
||||
Like its namesake's theory, Festinger treats contradiction not as an error to suppress but as a signal to act on.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
agent-zero containers
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────┐
|
||||
│ Festinger Proxy │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────────┐ │
|
||||
│ │ Loop │ │ Saliency Engine │ │
|
||||
│ │ Detector │ │ (tokenise+score) │ │
|
||||
│ └─────────────┘ └────────┬─────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼─────────┐ │
|
||||
│ │ SOAS │ │
|
||||
│ │ concept vocab │ │
|
||||
│ │ + saliency store │ │
|
||||
│ └─────────┬─────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────┤ │
|
||||
│ │ │ │
|
||||
│ ┌───────▼──────┐ ┌────────▼─────────┐ │
|
||||
│ │ Recollection │ │ Memory Writer │ │
|
||||
│ │ Engine │ │ (cloud LLM + │ │
|
||||
│ │ (read IN → │ │ NL → IN parser) │ │
|
||||
│ │ inject │ └────────┬─────────┘ │
|
||||
│ │ <recollec- │ │ │
|
||||
│ │ tion> block)│ ┌────────▼─────────┐ │
|
||||
│ └──────────────┘ │ Conflict │ │
|
||||
│ │ │ Resolver │ │
|
||||
│ │ └────────┬─────────┘ │
|
||||
│ └──────────┬───────┘ │
|
||||
│ │ │
|
||||
│ ┌▼──────────────────┐ │
|
||||
│ │ IN table │ │
|
||||
│ │ acyclic concept │ │
|
||||
│ │ graph │ │
|
||||
│ └───────────────────┘ │
|
||||
└───────────────────────────────────────┬──┘
|
||||
│
|
||||
▼
|
||||
Ollama (host)
|
||||
│
|
||||
▼ (write path only)
|
||||
Cloud LLM API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### models — LLM provider configuration
|
||||
|
||||
Festinger uses a cloud LLM for two purposes: the saliency-triggered write path and the nightly resolution job. Each purpose can use a different model.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | SERIAL PK | |
|
||||
| provider | VARCHAR | `claude` or `openai` |
|
||||
| model_name | VARCHAR | e.g. `claude-opus-4-6`, `gpt-4o` |
|
||||
| api_key | VARCHAR | stored encrypted at rest |
|
||||
| created_at | TIMESTAMPTZ | |
|
||||
|
||||
### config — runtime configuration
|
||||
|
||||
Key-value store for settings changeable without redeployment.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `write_model_id` | — | FK into models; used for saliency-triggered write path |
|
||||
| `resolve_model_id` | — | FK into models; used for nightly resolution job |
|
||||
| `saliency_read_threshold` | `0.5` | Minimum saliency to trigger recollection lookup |
|
||||
| `saliency_write_threshold` | `1.2` | Minimum saliency to trigger cloud LLM write |
|
||||
| `recollection_confidence_floor` | `0.6` | Minimum URD edge confidence to include in recollection |
|
||||
| `recollection_recency_days` | `90` | URD edges older than this are excluded |
|
||||
| `resolution_schedule` | `0 2 * * *` | Cron expression for nightly resolution job |
|
||||
|
||||
### SOAS — concept vocabulary and saliency
|
||||
|
||||
One row per token. No minimum token length — all tokens are indexed; frequency and novelty determine whether they surface. Common English words are pre-seeded at saliency 0 via a dictionary corpus. Dimensions are themselves SOAS concepts — no separate table needed.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | INT PK | auto-increment |
|
||||
| token | VARCHAR | unique, lowercase normalised |
|
||||
| encounter_count | INT | raw count across all intercepted prompts |
|
||||
| last_seen | TIMESTAMP | for recency tracking |
|
||||
| saliency | FLOAT | log-scaled encounter saliency; 0 = common English |
|
||||
| novelty | FLOAT | domain-specificity score; set to 1.0 when first confirmed by cloud LLM write path, 0 for pre-seeded dictionary words |
|
||||
|
||||
**Saliency vs novelty** are distinct scores serving different purposes:
|
||||
- `saliency` measures how frequently a concept appears — it drives read-threshold triggering
|
||||
- `novelty` measures how domain-specific a concept is — a system name that rarely appears but is clearly project-specific should still be treated as important
|
||||
|
||||
### URD table — the acyclic concept graph
|
||||
|
||||
Named `urd` (the SQL reserved word `IN` cannot be used as a table name). All three FK columns reference SOAS, so dimensions are concepts in the same vocabulary — new dimensions emerge from the same token space without schema changes.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | INT FK | references SOAS — the concept being placed |
|
||||
| parent_id | INT FK | references SOAS — the containing concept |
|
||||
| dim_id | INT FK | references SOAS — which dimension this edge belongs to |
|
||||
| is_isa | BOOLEAN | true = ISA (type/classification); false = ISPART (membership/containment). Outside the index — does not affect collision detection but drives conflict resolution semantics and recollection rendering |
|
||||
| confidence | FLOAT | reliability of this edge, 0.0–1.0; set by cloud LLM at write time, updated by conflict resolver |
|
||||
| last_confirmed | TIMESTAMPTZ | when was this edge last corroborated by an intercepted prompt; used for recency decay in recollection injection |
|
||||
| source | VARCHAR | `cloud_llm` (saliency write path), `inferred` (cue pattern), `festinger` (resolution job), `gutask` (gutask iknowthat) |
|
||||
|
||||
**Index structure:**
|
||||
- **PK**: `(id, parent_id, dim_id)` — full triple, prevents duplicate edges
|
||||
- **Unique index**: `(id, dim_id)` — one parent per concept per dimension; this is the acyclicity and contradiction-resistance mechanism
|
||||
- **Root nodes**: rows where `id = parent_id = dim_id` — the named root of a dimension tree; the one allowed self-reference
|
||||
|
||||
**Single relation:** IN — "this concept is semantically contained within that concept, within this dimension." ISA and ISPART are not separate relations or tables; they are the same IN relation, with `is_isa` annotating which flavour the edge represents.
|
||||
|
||||
### In-Memory Cache Layer
|
||||
|
||||
The proxy maintains three in-memory structures populated at startup from Postgres. All read operations hit these structures only — zero network on the hot path. Writes are write-through: in-memory first, then Postgres async (saliency updates) or sync (URD inserts).
|
||||
|
||||
```python
|
||||
# SOAS — primary lookup by token string (mirrors UNIQUE index on token)
|
||||
soas_by_token: dict[str, SoasRow]
|
||||
|
||||
# SOAS — reverse lookup by id (for pre-joining URD results)
|
||||
soas_by_id: dict[int, str]
|
||||
|
||||
# URD — recollection reads: concept_id → list of edges (tokens pre-joined)
|
||||
urd_by_concept: dict[int, list[UrdEdge]]
|
||||
|
||||
# URD — collision detection: (concept_id, dim_id) → edge
|
||||
# Mirrors the Postgres UNIQUE index on (id, dim_id) exactly
|
||||
urd_by_concept_dim: dict[tuple[int, int], UrdEdge]
|
||||
|
||||
# Resolution queue — concepts with pending conflicts (for ? marker in recollections)
|
||||
pending_conflicts: set[int]
|
||||
```
|
||||
|
||||
**New SOAS token flow** — ids always originate from Postgres:
|
||||
```
|
||||
token not in soas_by_token
|
||||
→ INSERT into Postgres SOAS → Postgres returns auto-increment id
|
||||
→ add to soas_by_token[token] and soas_by_id[id]
|
||||
→ proceed with that id
|
||||
```
|
||||
|
||||
**URD insert flow** — collision detected in-memory, Postgres is the safety net:
|
||||
```
|
||||
key = (concept_id, dim_id)
|
||||
if key in urd_by_concept_dim:
|
||||
→ collision detected in-memory
|
||||
→ classify type (is_isa flags), route to resolution queue
|
||||
→ return — no Postgres write attempted
|
||||
else:
|
||||
→ INSERT into Postgres URD
|
||||
→ on success: update urd_by_concept[concept_id] and urd_by_concept_dim[key]
|
||||
→ on UniqueViolation (race condition): reload row, route to resolution queue
|
||||
```
|
||||
|
||||
**Saliency update flow** — batched to avoid per-token Postgres writes:
|
||||
```
|
||||
every token encounter → update soas_by_token[token].encounter_count in-memory
|
||||
every 30 seconds → flush encounter count deltas to Postgres in one batch UPDATE
|
||||
```
|
||||
|
||||
**Cache reload after nightly job** — nightly job POSTs to `/reload` endpoint on proxy:
|
||||
```
|
||||
proxy receives /reload
|
||||
→ re-SELECT all URD rows from Postgres with pre-joined tokens
|
||||
→ rebuild urd_by_concept and urd_by_concept_dim
|
||||
→ rebuild pending_conflicts from resolution queue
|
||||
→ SOAS dict unchanged (nightly job does not modify SOAS)
|
||||
```
|
||||
|
||||
This separation means tests can inject mock data directly into the dicts without touching Postgres, enabling full unit testing of collision detection, recollection rendering, and queue routing.
|
||||
|
||||
---
|
||||
|
||||
### Canonical Recollection Query
|
||||
|
||||
The recollection engine executes this query for each salient concept found in an intercepted prompt. No chain traversal — depth is always 1. The result is a flat enumeration of all edges where the concept is the subject, across all dimensions.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
u.id,
|
||||
u.parent_id,
|
||||
u.dim_id,
|
||||
u.is_isa,
|
||||
p.token AS parent_token,
|
||||
d.token AS dim_token
|
||||
FROM urd u
|
||||
INNER JOIN soas p ON p.id = u.parent_id
|
||||
INNER JOIN soas d ON d.id = u.dim_id
|
||||
WHERE u.id = $1
|
||||
AND u.confidence >= $2
|
||||
AND u.last_confirmed >= $3
|
||||
ORDER BY u.id, u.dim_id DESC;
|
||||
```
|
||||
|
||||
`$1` = SOAS id of the concept, `$2` = confidence floor (from config), `$3` = recency cutoff (from config).
|
||||
|
||||
**Injection position:** the recollection block is prepended to the content of the existing system message if one is present. If no system message exists in the messages array, a new `{"role": "system"}` message containing only the recollection block is inserted at position 0. The system message is the highest-attention position in most instruction-tuned models — it is where grounding facts anchor most reliably.
|
||||
|
||||
**Rendering:** iterate rows; for each row emit `[dim_token] parent_token`. If the concept also has a pending entry in the resolution queue, append `?` to that dimension token. Group by concept when multiple concepts are queried in a single block.
|
||||
|
||||
**Zero-hit rendering:** if a concept is above the read threshold but has no URD entries, it is a salient domain-specific term the world model has not yet encountered. Instead of silently omitting it, the recollection block emits an explicit prompt to the agent:
|
||||
|
||||
```
|
||||
? gnommoweb: no recollection. If this is a typo, ignore.
|
||||
If you know what it is, store it before proceeding:
|
||||
gutask iknowthat 'gnommoweb -isa <parent> in context of <dimension>'
|
||||
gutask iknowthat 'gnommoweb -ispart <system> in context of <dimension>'
|
||||
```
|
||||
|
||||
This turns every unknown salient concept into an active instruction. The agent either confirms it is a typo, asks for clarification from a human or peer agent, or fills the gap itself via `gutask iknowthat`. The world model grows organically through use.
|
||||
|
||||
**Full example recollection block:**
|
||||
|
||||
```
|
||||
<recollection>
|
||||
gnommoweb: [glitch_university] repo [geography] ramanujan [type?] service
|
||||
dobby: [agent_pool] worker [tech] python
|
||||
? ramanujan: no recollection. If you know what it is, store it before proceeding:
|
||||
gutask iknowthat 'ramanujan -isa <parent> in context of <dimension>'
|
||||
gutask iknowthat 'ramanujan -ispart <system> in context of <dimension>'
|
||||
</recollection>
|
||||
```
|
||||
|
||||
`[type?]` signals a pending conflict on that dimension — the world model is not wrong, resolution is in progress.
|
||||
|
||||
### gutask iknowthat — Manual Write Path
|
||||
|
||||
`gutask iknowthat` is the highest-confidence write path into URD. It bypasses the saliency threshold and the cloud LLM entirely.
|
||||
|
||||
**Location:** `/Users/jenstandstad/Projects/gutasktool` (sibling of Agent0 repo). The command POSTs to Festinger's `/iknowthat` HTTP endpoint — gutasktool does not connect to Postgres directly.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```sh
|
||||
gutask iknowthat 'gnommoweb -isa repo in context of glitch_university'
|
||||
gutask iknowthat 'gnommoweb -ispart glitch_university in context of membership'
|
||||
```
|
||||
|
||||
- `-isa` sets `is_isa=true`; `-ispart` sets `is_isa=false`
|
||||
- `in context of <dimension>` specifies the dimension token; defaults to `type` for `-isa`, `membership` for `-ispart`
|
||||
- All tokens run through the standard tokeniser (compound token rule applies)
|
||||
- Inserts into SOAS if any token is new
|
||||
- Inserts into URD with `confidence=1.0`, `source=gutask`
|
||||
- On collision: enters resolution queue with priority flag; `gutask`-source conflicts are reviewed first at `/conflicts`
|
||||
|
||||
**Festinger `/iknowthat` endpoint:** accepts `POST` with JSON body `{fact: string}`. Parses, tokenises, writes to SOAS/URD, returns the inserted or conflicted result. This decouples gutasktool from the Festinger schema.
|
||||
|
||||
This command is the agent's direct interface to the world model. When the recollection block surfaces a zero-hit concept, `gutask iknowthat` is the prescribed response.
|
||||
|
||||
---
|
||||
|
||||
## The IN Relation and ISA/ISPART
|
||||
|
||||
### Why a single operator
|
||||
|
||||
ISA and ISPART are both instances of a more general semantic containment relation. The dimension carries the semantic weight:
|
||||
|
||||
- `gnommoweb IN repo` (dimension: `type`) → gnommoweb ISA repo
|
||||
- `gnommoweb IN Glitch-University` (dimension: `membership`) → gnommoweb ISPART Glitch-University
|
||||
- `State IN Country` (dimension: `type`) → State ISA a country-level granularity
|
||||
|
||||
The `type` dimension IS the ISA relation. Every other dimension IS an ISPART relation scoped to that domain. One table, one index, one operator.
|
||||
|
||||
### Why this resolves the bleed
|
||||
|
||||
The classic bleed case: Michigan ISA State, Michigan ISPART USA, State ISPART Country.
|
||||
|
||||
With the single IN operator and dimensions:
|
||||
|
||||
```
|
||||
Michigan IN State (dimension: type) is_isa: true
|
||||
Michigan IN USA (dimension: geography) is_isa: false
|
||||
State IN Country (dimension: type) is_isa: true
|
||||
USA IN Country (dimension: type) is_isa: true
|
||||
```
|
||||
|
||||
Two coherent chains, no collision, no ambiguity. "State ISPART Country" (class-level generalisation) becomes "State IN Country in the type dimension" — a perfectly valid ISA statement: State is a kind of country-level subdivision.
|
||||
|
||||
---
|
||||
|
||||
## Collision Semantics — The `is_isa` Flag
|
||||
|
||||
The unique index on `(concept_id, dimension_id)` fires when a concept already has a parent in a given dimension. The `is_isa` flag on both the existing and incoming rows determines what the collision means:
|
||||
|
||||
| Existing | Incoming | Interpretation | Action |
|
||||
|----------|----------|---------------|--------|
|
||||
| ISA | ISA | Dimension too coarse — both facts simultaneously true about the concept's nature | Trigger **dimension decomposition** |
|
||||
| ISPART | ISPART | Factual contradiction — thing can only be in one place per dimension | Trigger **arbitration** (which is correct?) |
|
||||
| ISA | ISPART | Dimension misclassification — these should have been in different dimensions | Flag as misclassification, suggest correct dimension |
|
||||
|
||||
---
|
||||
|
||||
## Dimension Decomposition — Dimensions as Evolving Vocabulary
|
||||
|
||||
When an ISA+ISA collision occurs, the dimension is too coarse to hold both simultaneously-true facts. Example:
|
||||
|
||||
- Existing: `gnommoweb IN container` (dimension: `type`, is_isa: true)
|
||||
- Incoming: `gnommoweb IN repo` (dimension: `type`, is_isa: true)
|
||||
|
||||
Both are true. The `type` dimension cannot hold them both. The conflict resolver sends this prompt to the cloud LLM:
|
||||
|
||||
> "gnommoweb is already `container` in dimension `type`. New fact also places gnommoweb as `repo` in `type`. If both are simultaneously true, propose two more specific dimension names to replace `type` — one where gnommoweb as `container` remains valid, one where gnommoweb as `repo` is valid. Return JSON: `{"existing_dimension": "...", "new_dimension": "..."}`. Choose from this taxonomy where possible: [...]. Create new names only if nothing fits."
|
||||
|
||||
The LLM might return: `{"existing_dimension": "deployment-type", "new_dimension": "artifact-type"}`.
|
||||
|
||||
The system then:
|
||||
1. Inserts `deployment-type` and `artifact-type` into SOAS (if not present)
|
||||
2. Creates root nodes for each new dimension
|
||||
3. Inserts the new fact under `artifact-type`
|
||||
4. Leaves the existing `type` facts untouched — no migration
|
||||
|
||||
Dimensions are SOAS concepts. New dimensions emerge from the same token vocabulary. The graph grows its own taxonomy under pressure from real contradictions, starting coarse and decomposing on demand.
|
||||
|
||||
---
|
||||
|
||||
## The Two Memory Operations
|
||||
|
||||
### Tokenisation Rules
|
||||
|
||||
All text — prompts, system messages, agent outputs — passes through the same tokeniser before any saliency or relationship work is done.
|
||||
|
||||
**Token extraction:**
|
||||
1. Split on whitespace and punctuation boundaries
|
||||
2. **Compound token rule**: scan for runs of consecutive tokens where each begins with a capital letter. Merge the run into a single token, joined with underscores, then lowercase. This canonicalises proper nouns and multi-word concepts into single SOAS entries.
|
||||
- `Glitch University` → `glitch_university`
|
||||
- `Agent Zero` → `agent_zero`
|
||||
- `New York City` → `new_york_city`
|
||||
- A lowercase or short token breaks the run: `the Glitch University` → `the` breaks the run, `Glitch University` → `glitch_university`
|
||||
3. Lowercase all tokens
|
||||
4. Keep tokens with **5 or more characters** (strictly >4); discard shorter tokens unless they are part of a matched relationship cue pattern (see below)
|
||||
5. Strip leading/trailing punctuation from each token
|
||||
|
||||
**Example:**
|
||||
`"gnommoweb is a repo of Glitch University"`
|
||||
→ tokens: `gnommoweb`, `repo`, `glitch_university`
|
||||
→ relationship extracted: `gnommoweb IN repo IN dim:glitch_university` (is_isa=true, via "is a … of" pattern)
|
||||
|
||||
---
|
||||
|
||||
### Relationship Cue Patterns
|
||||
|
||||
Certain keyword patterns in intercepted text are direct cues to the memory layer that a semantic relationship is being expressed. The middleman scans every prompt for these patterns. When matched, the relationship is extracted and queued for insertion into the IN table — bypassing the saliency threshold, since the relationship has been made explicit.
|
||||
|
||||
Agents and humans interacting with Agent0 should be aware that using these patterns causes the middleman to build or update the world model.
|
||||
|
||||
**ISA patterns** (`is_isa = true`) — the subject is a type or instance of the object:
|
||||
|
||||
| Pattern | Example |
|
||||
|---------|---------|
|
||||
| `{X} is a {Y}` | gnommoweb is a repo |
|
||||
| `{X} is an {Y}` | gnommoweb is an API |
|
||||
| `{X} ISA {Y}` | gnommoweb ISA repo |
|
||||
| `{X} is a kind of {Y}` | State is a kind of region |
|
||||
| `{X} is a type of {Y}` | gnommoweb is a type of service |
|
||||
| `{X} is an instance of {Y}` | dobby is an instance of agent |
|
||||
| `{X} kind of {Y}` | gnommoweb kind of repo |
|
||||
| `{X} type of {Y}` | gnommoweb type of service |
|
||||
| `{X} instance of {Y}` | dobby instance of agent |
|
||||
|
||||
**ISPART patterns** (`is_isa = false`) — the subject is a member, part, or component of the object:
|
||||
|
||||
| Pattern | Example |
|
||||
|---------|---------|
|
||||
| `{X} is part of {Y}` | gnommoweb is part of Glitch University |
|
||||
| `{X} ISPART {Y}` | gnommoweb ISPART glitch_university |
|
||||
| `{X} part of {Y}` | gnommoweb part of Agent0 |
|
||||
| `{X} belongs to {Y}` | gnommoweb belongs to Glitch University |
|
||||
| `{X} is owned by {Y}` | gnommoweb is owned by jenstandstad |
|
||||
| `{X} owned by {Y}` | gnommoweb owned by jenstandstad |
|
||||
| `{X} member of {Y}` | dobby member of agent_pool |
|
||||
| `{X} is a member of {Y}` | dobby is a member of agent_pool |
|
||||
| `{X} runs on {Y}` | gnommoweb runs on Docker |
|
||||
| `{X} hosted by {Y}` | gnommoweb hosted by ramanujan |
|
||||
| `{X} deployed on {Y}` | gnommoweb deployed on Docker |
|
||||
| `{X} contained in {Y}` | gnommoweb contained in agent0_stack |
|
||||
|
||||
**The `of {Z}` dimension modifier:**
|
||||
|
||||
When an ISA pattern is followed by `of {Z}`, the named entity `{Z}` becomes the dimension for the extracted edge. This allows natural language to directly specify the context in which a classification holds:
|
||||
|
||||
```
|
||||
"gnommoweb is a repo of Glitch University"
|
||||
→ gnommoweb IN repo IN dim:glitch_university (is_isa=true)
|
||||
|
||||
"Michigan is a state of USA"
|
||||
→ michigan IN state IN dim:usa (is_isa=true)
|
||||
```
|
||||
|
||||
Without the `of {Z}` modifier, the dimension defaults to `type` for ISA patterns and the most appropriate seed dimension for ISPART patterns (inferred by the cloud LLM during the write step, or defaulting to `membership`).
|
||||
|
||||
**Cue-triggered writes bypass the saliency threshold.** Explicit relationship cues are treated as high-confidence signals regardless of how many times a concept has previously appeared. The extracted triple goes directly into the write queue with `source: inferred` and a confidence score assigned by the pattern type (exact keyword cues score higher than positional inference).
|
||||
|
||||
---
|
||||
|
||||
### Writing — cloud-triggered, async
|
||||
|
||||
When a concept's saliency crosses the **write threshold**, Middleman queues it for background processing (cloud LLM calls must not block the prompt response path):
|
||||
|
||||
1. Call cloud LLM: "What is `{concept}`?" with the current dimension taxonomy as a closed list and a structured output prompt requesting `(concept, parent, dimension, is_isa)` triples
|
||||
2. Parse response into IN table INSERT statements
|
||||
3. Attempt inserts; route constraint violations to the conflict resolver
|
||||
4. Update `novelty` score in SOAS for the concept
|
||||
|
||||
### Reading — spontaneous prompt enrichment
|
||||
|
||||
On every intercepted prompt:
|
||||
|
||||
1. Tokenise the full prompt string, extract tokens >4 chars, normalise
|
||||
2. Look up each token in SOAS, update encounter counts and saliency
|
||||
3. For tokens above the **read threshold**, query the IN table for all edges involving the concept
|
||||
4. Traverse each chain upward (configurable max depth)
|
||||
5. Format as a `<recollection>` block and prepend to the prompt before forwarding to Ollama
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
<recollection>
|
||||
gnommoweb: [type] repo → software-artifact
|
||||
[membership] Glitch-University → Agent0-infrastructure
|
||||
[tech] FastAPI → Python
|
||||
glitch.university: [type] platform → web-service
|
||||
[membership] Agent0 → Glitch-Hunter-project
|
||||
</recollection>
|
||||
```
|
||||
|
||||
Only edges above the confidence threshold and within the recency window are included. The agent does not search for these — they appear spontaneously.
|
||||
|
||||
**Read and write thresholds are separate and independently tunable.** Reading (DB lookup) is cheap; writing (cloud LLM call) is expensive. The write threshold should be meaningfully higher than the read threshold.
|
||||
|
||||
---
|
||||
|
||||
## Conflict Resolution — The Nightly Therapy Model
|
||||
|
||||
The IN table is rigorous and autistic: it cannot hold contradictions. Any collision is immediately routed to the **resolution queue** — a separate table — where it waits for the nightly resolution job to process it. During this period the world model stands unchanged; the old fact continues to be served in recollections, marked with a `?` to signal pending dissonance.
|
||||
|
||||
### On collision (immediate, synchronous)
|
||||
|
||||
1. Classify the collision type by reading `is_isa` on both rows: ISA+ISA, ISPART+ISPART, or misclassification
|
||||
2. Insert the rejected fact into the **resolution queue** with full context: existing edge, incoming edge, dimension, collision type, timestamp
|
||||
3. Return normally — the proxy response is not blocked
|
||||
|
||||
### During the day (recollection engine)
|
||||
|
||||
Concepts with entries in the resolution queue are rendered with a `?` marker:
|
||||
|
||||
```
|
||||
<recollection>
|
||||
gnommoweb: [type] container
|
||||
[type?] repo — pending resolution
|
||||
</recollection>
|
||||
```
|
||||
|
||||
The agent sees that a fact is contested. The world model is not wrong — it is incomplete. The `?` marker disappears after the nightly job resolves or dismisses the conflict.
|
||||
|
||||
### Nightly resolution job
|
||||
|
||||
Runs as a background thread inside the Festinger proxy process on the schedule set in the `config` table (`resolution_schedule`). Can also be triggered manually via `POST /resolve/run` — a corresponding button is exposed in the Festinger admin UI. Uses the model configured in `resolve_model_id`.
|
||||
|
||||
For each item in the queue, calls the configured LLM with both facts and the collision type, receives a structured decision, and applies it:
|
||||
|
||||
**ISA+ISA collision (dimension too coarse):**
|
||||
- LLM outcome A — **decompose**: suggest two new dimension names. System creates new SOAS entries and root nodes, inserts both facts into their respective new dimensions, marks queue item resolved.
|
||||
- LLM outcome B — **dismiss**: the incoming fact is noise or wrong. Queue item marked dismissed. World model stands.
|
||||
|
||||
**ISPART+ISPART collision (factual contradiction):**
|
||||
- LLM outcome A — **update**: the incoming fact is more current. System removes old IN edge, inserts new one, marks queue item resolved.
|
||||
- LLM outcome B — **dismiss**: the existing fact is still correct. Queue item marked dismissed.
|
||||
|
||||
**Misclassification (ISA+ISPART in same dimension):**
|
||||
- LLM suggests the correct dimension for the incoming fact. System inserts it in the corrected dimension (no collision), marks queue item resolved.
|
||||
|
||||
### Resolution queue schema
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | INT PK | auto-increment |
|
||||
| concept_id | INT FK | references SOAS |
|
||||
| existing_parent_id | INT FK | the parent currently in the IN table |
|
||||
| incoming_parent_id | INT FK | the rejected parent |
|
||||
| dimension_id | INT FK | the dimension where the collision occurred |
|
||||
| collision_type | ENUM | `isa_isa`, `ispart_ispart`, `misclassification` |
|
||||
| status | ENUM | `pending`, `resolved`, `dismissed` |
|
||||
| resolution | TEXT | JSON record of what the nightly job decided and did |
|
||||
| created_at | TIMESTAMP | when the collision occurred |
|
||||
| resolved_at | TIMESTAMP | when the nightly job processed it |
|
||||
|
||||
### Properties of this model
|
||||
|
||||
- **IN table is always consistent** — agents never receive contradictory recollections from confirmed facts
|
||||
- **Resolution is deliberate, not reactive** — the nightly job processes dissonance calmly, with full context, not under prompt-response time pressure
|
||||
- **Dismissal is a first-class outcome** — not every collision is a real problem; the LLM can decide the world model is correct and the incoming fact was noise
|
||||
- **The `?` marker is the fuzziness adjunct** — it surfaces uncertainty to agents without compromising the graph's integrity
|
||||
- **`/conflicts` endpoint** — exposes the full queue (pending and recently resolved) for human inspection and override
|
||||
|
||||
---
|
||||
|
||||
## Graph Properties
|
||||
|
||||
- **Acyclic**: the unique index on `(concept_id, dimension_id)` enforces single-parent per concept per dimension, making each dimension a forest of trees — structurally acyclic without any runtime check
|
||||
- **Shared**: one graph for all agents — recollections represent shared facts about the project, not per-agent beliefs. Agents already have per-agent memory in Agent Zero's own FAISS layer
|
||||
- **Contradiction-resistant**: the index makes it structurally impossible to store conflicting facts in the same dimension. Contradictions surface as insert failures, not silent overwrites
|
||||
- **Self-organising**: dimension taxonomy starts with a small seed list and decomposes on demand. No human needs to pre-define the full ontology
|
||||
|
||||
---
|
||||
|
||||
## Saliency Decay
|
||||
|
||||
Two decay mechanisms, operating independently:
|
||||
|
||||
- **SOAS recency** (`last_seen` timestamp): concepts not seen for a long time are deprioritised for recollection injection but not deleted. If gnommoweb reappears in a prompt, `last_seen` updates and its recollections resurface immediately
|
||||
- **IN edge recency** (`last_confirmed` timestamp): edges not corroborated by recent prompts are given lower weight in recollection injection, making the recollection block favour currently-relevant facts
|
||||
|
||||
Decay does not delete knowledge — it adjusts injection priority. The graph remains intact.
|
||||
|
||||
---
|
||||
|
||||
## Dimension Taxonomy — Seed List
|
||||
|
||||
A small, orthogonal set of initial dimensions. Each dimension answers a specific question about a concept. New dimensions emerge through decomposition; this list is the starting skeleton.
|
||||
|
||||
| Dimension | Question | is_isa |
|
||||
|-----------|----------|--------|
|
||||
| `type` | What kind of thing is this? | true |
|
||||
| `membership` | What system or project does this belong to? | false |
|
||||
| `runs-on` | What infrastructure hosts or executes this? | false |
|
||||
| `tech` | What technology stack is it built with? | false |
|
||||
| `owned-by` | Who is responsible for this? | false |
|
||||
| `geography` | Where is this spatially or organisationally located? | false |
|
||||
|
||||
Root nodes for each dimension are seeded at bootstrap. The `type` dimension is expected to be the first to decompose as domain-specific concepts accumulate.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Description | State |
|
||||
|-----------|-------------|-------|
|
||||
| Proxy core | FastAPI Ollama-compatible HTTP proxy | ✅ built |
|
||||
| Loop detector | Session-scoped repeat detection + mitigations | ✅ built |
|
||||
| Config system | Hot-reloading YAML config | ✅ built |
|
||||
| SOAS store | Concept vocabulary + saliency DB table | ✅ built |
|
||||
| IN table store | Acyclic concept graph with correct indexes | ✅ built |
|
||||
| Dictionary bootstrap | Pre-seed SOAS with common English at saliency 0 | ✅ built |
|
||||
| Dimension bootstrap | Seed root nodes for initial dimension taxonomy | ✅ built |
|
||||
| Saliency engine | Tokenise prompt, score tokens, update SOAS counts | ✅ built |
|
||||
| Recollection engine | Query IN table, traverse chains, format + inject block | ✅ built |
|
||||
| Memory writer | Write-threshold trigger → async cloud LLM → NL→IN parse → insert | ✅ built |
|
||||
| Conflict resolver | On collision: classify type, insert into resolution queue immediately | ✅ built |
|
||||
| Resolution queue | Pending/resolved/dismissed conflicts with full context | ✅ built |
|
||||
| Nightly resolution job | Drain queue via cloud LLM; apply decompose/update/dismiss decisions | ✅ built |
|
||||
| `/conflicts` endpoint | Expose queue (pending + recent) for human inspection and override | ✅ built |
|
||||
| Persistence | Postgres from day one; English dictionary pre-loaded into SOAS at init | ✅ built |
|
||||
|
||||
---
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### Phase 1 — Foundation (complete)
|
||||
- [x] **T01** Proxy core: FastAPI Ollama-compatible server forwarding `/api/chat` and `/api/generate`
|
||||
- [x] **T02** Loop detector: session-scoped exact-match repetition detection
|
||||
- [x] **T03** Mitigations: temperature boost, forbidden action injection, history truncation, circuit breaker
|
||||
- [x] **T04** Hot-reload YAML config
|
||||
- [x] **T05** Docker container + docker-compose service entry
|
||||
|
||||
### Phase 2 — Persistence Layer
|
||||
- [x] **T06** Postgres service: add `postgres` container to docker-compose; connection config in `config.yaml`
|
||||
- [x] **T07** `models` table: `id` SERIAL PK, `provider` VARCHAR, `model_name` VARCHAR, `api_key` VARCHAR, `created_at` TIMESTAMPTZ
|
||||
- [x] **T08** `config` table: `key` VARCHAR PK, `value` TEXT, `updated_at` TIMESTAMPTZ; seed with default values for all config keys
|
||||
- [x] **T09** SOAS table: `id` SERIAL PK, `token` VARCHAR UNIQUE, `encounter_count` INT default 0, `last_seen` TIMESTAMPTZ, `saliency` FLOAT default 0, `novelty` FLOAT default 0. All tokens lowercase. Unique index on `token`.
|
||||
- [x] **T10** URD table: `id` INT FK → soas, `parent_id` INT FK → soas, `dim_id` INT FK → soas, `is_isa` BOOLEAN, `confidence` FLOAT, `last_confirmed` TIMESTAMPTZ, `source` VARCHAR. PK `(id, parent_id, dim_id)`. Unique index `(id, dim_id)`.
|
||||
- [x] **T11** Resolution queue table: `id` SERIAL PK, `concept_id` INT FK → soas, `existing_parent_id` INT FK → soas, `incoming_parent_id` INT FK → soas, `dim_id` INT FK → soas, `collision_type` VARCHAR, `status` VARCHAR default 'pending', `resolution` JSONB, `created_at` TIMESTAMPTZ, `resolved_at` TIMESTAMPTZ
|
||||
- [x] **T12** English dictionary bootstrap: bulk-load word list into SOAS with `saliency=0`, `novelty=0`, `encounter_count=0` on container init. Skip existing tokens.
|
||||
- [x] **T13** Dimension bootstrap: insert SOAS entries and self-referential URD root nodes (`id = parent_id = dim_id`) for the 6 seed dimensions
|
||||
|
||||
### Phase 3 — Saliency Engine and Prompt Parsing
|
||||
- [x] **T14** Tokeniser: split on whitespace/punctuation; apply compound token rule (consecutive capitalised tokens → single underscore-joined lowercase token); no minimum length; strip punctuation; lowercase all
|
||||
- [x] **T15** Relationship cue scanner: regex/pattern scan for ISA and ISPART cue patterns; extract `(subject, parent, dimension_modifier, is_isa)` triples; handle `of {Z}` dimension modifier
|
||||
- [x] **T16** SOAS lookup + update: increment `encounter_count`, update `last_seen`, recalculate `saliency` (log scale) for all extracted tokens; read thresholds from `config` table
|
||||
- [x] **T17** Threshold evaluation: read `saliency_read_threshold` and `saliency_write_threshold` from config; cue-extracted triples bypass write-threshold entirely
|
||||
|
||||
### Phase 4 — Recollection Engine (Read Path)
|
||||
- [x] **T16** URD query: for each above-read-threshold token, execute the canonical recollection query; filter by `confidence` floor and `last_confirmed` recency window
|
||||
- [x] **T17** Recollection formatter — hit path: enumerate query rows; group by concept; render each edge as `[dim_token] parent_token`; append `?` for edges with a pending resolution queue entry
|
||||
- [x] **T18** Recollection formatter — zero-hit path: for salient concepts with no URD rows, emit the `? concept: no recollection` prompt block including the `gutask iknowthat` usage hint
|
||||
- [x] **T19** Prompt injection: prepend recollection block before forwarding to Ollama
|
||||
- [x] **T20** Recollection config: max concepts per block, confidence floor, recency window, injection position
|
||||
|
||||
### Phase 5 — Memory Writer (Write Path)
|
||||
- [x] **T21** `POST /iknowthat` endpoint: accept `{fact: string}`, parse `-isa`/`-ispart` flags and `in context of` clause, run tokeniser, upsert SOAS, insert into URD with `confidence=1.0, source=gutask`; route collisions to resolution queue with priority flag
|
||||
- [x] **T22** `gutask iknowthat` command (in `/Users/jenstandstad/Projects/gutasktool`): parse fact string, POST to Festinger `/iknowthat`, surface result to agent
|
||||
- [x] **T23** Write queue: async background queue for concepts crossing the write threshold; cue-extracted triples enter directly regardless of threshold
|
||||
- [x] **T24** LLM client: support `claude` and `openai` providers; load provider/model/key from `models` table via `write_model_id` config key; structured prompt requesting `(concept, parent, dimension, is_isa, confidence)` triples as JSON
|
||||
- [x] **T25** NL→IN parser: validate triples against SOAS and known dimensions; create new SOAS entries for unknown tokens; apply compound token rule
|
||||
- [x] **T26** URD insert pipeline: check `urd_by_concept_dim` in-memory first; on miss attempt Postgres insert; on hit or UniqueViolation route to conflict resolver; set `source` field per write path
|
||||
|
||||
### Phase 6 — Conflict Resolution
|
||||
- [x] **T25** Collision handler: on unique constraint violation, classify type via `is_isa` flags, insert immediately into resolution queue
|
||||
- [x] **T26** Recollection engine update: check resolution queue for pending items per concept; render pending edges with `[dim?]` marker
|
||||
- [x] **T27** Nightly resolution job: background thread, schedule from `config` table (`resolution_schedule`); for each `pending` queue item, call LLM configured in `resolve_model_id` with both facts and collision type, receive JSON decision (`decompose` / `update` / `dismiss`)
|
||||
- [x] **T28** Resolution applicator — decompose: create new dimension SOAS entries and root nodes; insert both facts in respective new dimensions; mark queue item resolved
|
||||
- [x] **T29** Resolution applicator — update: remove old IN edge, insert new fact, mark queue item resolved
|
||||
- [x] **T30** Resolution applicator — dismiss: mark queue item dismissed; world model unchanged; the `[dim?]` marker disappears from recollections
|
||||
- [x] **T31** `/conflicts` endpoint: list pending and recently resolved/dismissed items with full context; support human override (force-dismiss, force-resolve)
|
||||
- [x] **T32** `POST /resolve/run` endpoint: manually trigger the nightly resolution job outside of its schedule
|
||||
- [x] **T33** Admin UI: minimal HTML page served by Festinger at `/admin`; shows pending conflicts count, last resolution run timestamp, and a "Run resolution now" button wired to `POST /resolve/run`
|
||||
|
||||
### Phase 7 — Hardening
|
||||
- [ ] **T32** Latency guard: tokenisation + saliency lookup + recollection query must not add >50ms to prompt round-trip; use connection pooling
|
||||
- [ ] **T33** Write path fully async: all cloud LLM calls, IN inserts, and queue operations run in background workers; proxy response never waits
|
||||
- [ ] **T34** Integration test: plain prompt → compound token extraction → saliency update → recollection injection → Ollama round trip
|
||||
- [ ] **T35** Integration test: cue pattern in prompt ("gnommoweb is a repo of Glitch University") → extracted triple bypasses threshold → IN insert → recollection on next prompt
|
||||
- [ ] **T36** Integration test: write threshold → cloud LLM write → IN insert → recollection on next prompt
|
||||
- [ ] **T37** Integration test: ISA+ISA collision → immediate queue insert → `[type?]` marker in recollection → nightly job → decompose → clean recollection across two dimensions
|
||||
- [ ] **T38** Integration test: ISPART+ISPART collision → queue insert → nightly job → dismiss → world model unchanged, marker gone
|
||||
|
||||
---
|
||||
|
||||
## Resolved Design Decisions
|
||||
|
||||
| Question | Decision |
|
||||
|----------|----------|
|
||||
| Single relation or ISA+ISPART tables? | Single IN operator; `is_isa` boolean flag outside the index annotates edge type |
|
||||
| ISA vs ISPART bleed | Resolved by dimension: `type` dimension = ISA; all other dimensions = ISPART |
|
||||
| Shared vs per-agent graph | Shared — recollections are project-wide facts; per-agent memory remains in Agent Zero's FAISS layer |
|
||||
| Saliency decay | Dual mechanism: SOAS `last_seen` for concept recency; IN `last_confirmed` for edge recency. Decay adjusts injection priority, never deletes knowledge |
|
||||
| Recollection depth | Depth = 1, no chain traversal. Single flat query against URD for direct edges of each salient concept. Property-loop problem is dissolved by design — self-referential chains cannot arise without traversal. |
|
||||
| Write path blocking | Fully async — cloud LLM calls queued in background, never block proxy response |
|
||||
| Dimension taxonomy | Seed list of 6 orthogonal dimensions; decomposes on demand via nightly resolution job when ISA+ISA collision is queued |
|
||||
| Collision semantics | ISA+ISA → dimension too coarse → decompose. ISPART+ISPART → factual contradiction → arbitrate. ISA+ISPART → misclassification → flag |
|
||||
| Contradiction resistance | Structural — unique index on `(concept_id, dimension_id)` makes conflicting facts physically uninsertable in the same dimension |
|
||||
| New dimensions | Emerge as SOAS tokens; no schema change needed; root nodes created at decomposition time |
|
||||
| Conflict resolution timing | Immediate queue insert on collision; nightly job drains the queue via cloud LLM; outcomes are decompose / update / dismiss |
|
||||
| Database | Postgres from day one — no SQLite POC. English dictionary bulk-loaded into SOAS at container init. |
|
||||
| Tokenisation | Tokens ≥5 chars, lowercased. Consecutive capitalised tokens merged into single underscore-joined token (`Glitch University` → `glitch_university`). |
|
||||
| Relationship cue parsing | ISA and ISPART keyword patterns in intercepted text trigger direct triple extraction, bypassing saliency threshold. `of {Z}` modifier sets the dimension. |
|
||||
| Cue-triggered writes | Explicit cue patterns are high-confidence signals; extracted triples go straight to the write queue with `source: inferred`, no threshold gate. |
|
||||
| Zero-hit recollection | Salient concepts with no URD entries emit a `? concept: no recollection` prompt block instructing the agent to clarify or store the fact via `gutask iknowthat`. |
|
||||
| Manual write path | `gutask iknowthat 'X -isa/-ispart Y in context of Z'` inserts directly into URD with `confidence=1.0, source=manual`, bypassing saliency threshold and cloud LLM. |
|
||||
| In-memory layer | SOAS and URD cached in Python dicts at startup. Reads are zero-network. IDs always originate from Postgres. Collision detection uses `dict[(concept_id, dim_id)]` mirroring the Postgres unique index. Postgres is the safety net for race conditions. |
|
||||
| Token length | No minimum — all tokens indexed. Frequency and novelty determine whether they surface. Optimize later if needed. |
|
||||
| Nightly job execution | Background thread inside proxy process. Triggered by cron schedule (config table) or manually via `POST /resolve/run` + admin UI button. |
|
||||
| Recollection injection | Prepended to existing system message content. If no system message exists, a new one is inserted at position 0. System message position provides the strongest grounding anchor for instruction-tuned models. |
|
||||
| LLM configuration | `models` table (provider, model_name, api_key). `config` table keys `write_model_id` and `resolve_model_id` select which model each purpose uses. Supports `claude` and `openai` providers. |
|
||||
| Source values | `cloud_llm` (saliency write path), `inferred` (cue pattern extraction), `festinger` (nightly resolution job), `gutask` (gutask iknowthat command). |
|
||||
| gutask iknowthat interface | gutasktool at `/Users/jenstandstad/Projects/gutasktool`. Command POSTs to Festinger's `/iknowthat` endpoint — no direct Postgres access from gutasktool. |
|
||||
|
||||
---
|
||||
|
||||
## Test Cases
|
||||
|
||||
The in-memory cache layer enables full unit testing without a live database. Tests pre-populate `soas_by_token`, `soas_by_id`, `urd_by_concept`, `urd_by_concept_dim`, and `pending_conflicts` directly, then exercise the logic under test and assert on the resulting state.
|
||||
|
||||
---
|
||||
|
||||
### Test A — Prompt includes a concept not in the cache
|
||||
|
||||
**Scenario:** An agent sends a prompt referencing `gnommoweb`. The concept exists nowhere in SOAS or URD.
|
||||
|
||||
**Setup:**
|
||||
```python
|
||||
soas_by_token = {} # empty — concept is completely unknown
|
||||
urd_by_concept = {}
|
||||
urd_by_concept_dim = {}
|
||||
pending_conflicts = set()
|
||||
```
|
||||
|
||||
**Input prompt:** `"Please update gnommoweb to use FastAPI instead"`
|
||||
|
||||
**Expected behaviour:**
|
||||
1. Tokeniser extracts: `gnommoweb` (7 chars ✓), `fastapi` (7 chars ✓), `please` (6 chars but common English → saliency 0), `update` (6 chars, common → saliency 0), `instead` (7 chars, common → saliency 0)
|
||||
2. `gnommoweb` and `fastapi` not in `soas_by_token` → both are new tokens. In a test with Postgres mocked, the mock returns `id=101` for `gnommoweb` and `id=102` for `fastapi`. Both added to SOAS dicts.
|
||||
3. Saliency for both: `log(1)` — first encounter, below read threshold
|
||||
4. URD lookup: skipped (below threshold) — no recollection block emitted for these concepts
|
||||
|
||||
**Edge case variant — above threshold:** pre-seed `soas_by_token["gnommoweb"]` with `encounter_count=50, saliency=0.9` (above read threshold) but keep `urd_by_concept` empty.
|
||||
|
||||
**Expected behaviour (variant):**
|
||||
1. Saliency lookup: above read threshold ✓
|
||||
2. URD lookup: `urd_by_concept.get(101, [])` → empty list
|
||||
3. Zero-hit path triggered
|
||||
4. Recollection block contains:
|
||||
```
|
||||
? gnommoweb: no recollection. If not a typo, store it before proceeding:
|
||||
gutask iknowthat 'gnommoweb -isa <parent> in context of <dimension>'
|
||||
gutask iknowthat 'gnommoweb -ispart <system> in context of <dimension>'
|
||||
```
|
||||
|
||||
**Assertions:**
|
||||
- `pending_conflicts` unchanged (no collision occurred)
|
||||
- Resolution queue empty
|
||||
- Recollection block contains `? gnommoweb`
|
||||
- Prompt forwarded to Ollama with recollection block prepended
|
||||
|
||||
---
|
||||
|
||||
### Test B — Prompt includes "A ISA B" conflicting with existing world model
|
||||
|
||||
**Scenario:** The world model already holds `gnommoweb ISA repo` in the `type` dimension. A new prompt contains the explicit cue `"gnommoweb is a container"`, creating an ISA+ISA collision.
|
||||
|
||||
**Setup:**
|
||||
```python
|
||||
# SOAS
|
||||
soas_by_token = {
|
||||
"gnommoweb": SoasRow(id=101, saliency=1.2, novelty=1.0),
|
||||
"repo": SoasRow(id=201, saliency=0.8, novelty=0.5),
|
||||
"container": SoasRow(id=202, saliency=0.6, novelty=0.4),
|
||||
"type": SoasRow(id=1, saliency=0.0, novelty=0.0), # seed dimension
|
||||
}
|
||||
soas_by_id = {101: "gnommoweb", 201: "repo", 202: "container", 1: "type"}
|
||||
|
||||
# URD — gnommoweb ISA repo IN type (existing confirmed fact)
|
||||
existing_edge = UrdEdge(concept_id=101, parent_id=201, dim_id=1,
|
||||
is_isa=True, confidence=0.9, source="cloud_llm")
|
||||
urd_by_concept = {101: [existing_edge]}
|
||||
urd_by_concept_dim = {(101, 1): existing_edge}
|
||||
pending_conflicts = set()
|
||||
```
|
||||
|
||||
**Input prompt:** `"gnommoweb is a container deployed on Docker"`
|
||||
|
||||
**Expected behaviour:**
|
||||
1. Cue scanner matches `"is a"` pattern: extracts triple `(gnommoweb, container, type, is_isa=True)`
|
||||
2. Collision check: `(101, 1)` found in `urd_by_concept_dim` → collision
|
||||
3. Existing edge `is_isa=True`, incoming `is_isa=True` → ISA+ISA collision type
|
||||
4. Resolution queue insert (mocked Postgres): `{concept_id=101, existing_parent_id=201, incoming_parent_id=202, dim_id=1, collision_type="isa_isa", status="pending"}`
|
||||
5. `pending_conflicts.add(101)`
|
||||
6. No URD modification — world model unchanged
|
||||
|
||||
**Recollection block for this prompt:**
|
||||
```
|
||||
<recollection>
|
||||
gnommoweb: [type?] repo — conflict pending
|
||||
</recollection>
|
||||
```
|
||||
|
||||
**Assertions:**
|
||||
- `urd_by_concept_dim[(101, 1)]` still points to the original `repo` edge (unchanged)
|
||||
- `pending_conflicts == {101}`
|
||||
- Resolution queue has exactly one entry with `collision_type="isa_isa"` and `status="pending"`
|
||||
- No Postgres URD insert attempted
|
||||
- Recollection renders `[type?]` marker for `gnommoweb`
|
||||
|
||||
---
|
||||
|
||||
### Test C — Nightly job: queue processing and dimension decomposition
|
||||
|
||||
**Scenario:** The resolution queue contains the ISA+ISA collision from Test B. The nightly job runs, calls the cloud LLM (mocked), receives a decomposition decision, and updates the world model.
|
||||
|
||||
**Setup:** state as at end of Test B, plus:
|
||||
```python
|
||||
resolution_queue = [
|
||||
QueueEntry(id=1, concept_id=101, existing_parent_id=201, incoming_parent_id=202,
|
||||
dim_id=1, collision_type="isa_isa", status="pending")
|
||||
]
|
||||
```
|
||||
|
||||
**Mocked cloud LLM response:**
|
||||
```json
|
||||
{
|
||||
"decision": "decompose",
|
||||
"existing_dimension": "artifact-type",
|
||||
"new_dimension": "deployment-type",
|
||||
"reasoning": "repo describes what gnommoweb is as a software artifact; container describes how it is deployed"
|
||||
}
|
||||
```
|
||||
|
||||
**Expected behaviour:**
|
||||
1. Nightly job fetches pending queue entries from Postgres
|
||||
2. For entry id=1, calls cloud LLM → receives decompose decision
|
||||
3. **New dimensions:** insert `artifact-type` into Postgres SOAS → returns `id=401`; insert `deployment-type` → returns `id=402`. Add both to SOAS dicts.
|
||||
4. **Root nodes:** insert `(401,401,401)` and `(402,402,402)` into Postgres URD (self-referential dimension roots)
|
||||
5. **Migrate existing edge:** delete `(101, 201, 1)` from URD, insert `(101, 201, 401)` — gnommoweb ISA repo IN artifact-type
|
||||
6. **Insert new edge:** insert `(101, 202, 402)` — gnommoweb ISA container IN deployment-type
|
||||
7. **Update in-memory cache:**
|
||||
- `soas_by_token["artifact-type"] = SoasRow(id=401, ...)`
|
||||
- `soas_by_token["deployment-type"] = SoasRow(id=402, ...)`
|
||||
- Remove `urd_by_concept_dim[(101, 1)]`, add `[(101, 401)]` and `[(101, 402)]`
|
||||
- Update `urd_by_concept[101]` to two new edges
|
||||
- `pending_conflicts.discard(101)`
|
||||
8. Mark queue entry resolved with resolution JSON
|
||||
9. Signal proxy `/reload` (or nightly job updates cache directly if in-process)
|
||||
|
||||
**Final recollection block for gnommoweb:**
|
||||
```
|
||||
<recollection>
|
||||
gnommoweb: [artifact-type] repo [deployment-type] container
|
||||
</recollection>
|
||||
```
|
||||
|
||||
**Assertions:**
|
||||
- `urd_by_concept_dim` contains `(101, 401)` and `(101, 402)`, not `(101, 1)`
|
||||
- `urd_by_concept[101]` has exactly two edges
|
||||
- `pending_conflicts` does not contain `101`
|
||||
- `soas_by_token` contains `artifact-type` and `deployment-type` with ids from Postgres
|
||||
- Resolution queue entry has `status="resolved"` and non-null `resolution` JSON
|
||||
- No `[type?]` marker in subsequent recollection for gnommoweb
|
||||
@@ -0,0 +1,57 @@
|
||||
# Festinger Proxy Configuration
|
||||
|
||||
# Where the real Ollama is running (for /api/chat, /api/generate, /api/*)
|
||||
upstream_ollama: "http://host.docker.internal:11434"
|
||||
|
||||
# Where the real Anthropic API is running (for /v1/messages)
|
||||
# Override via UPSTREAM_ANTHROPIC env var if needed
|
||||
upstream_anthropic: "https://api.anthropic.com"
|
||||
|
||||
# Where the real OpenAI-compatible API is running (for /v1/chat/completions)
|
||||
# Override via UPSTREAM_OPENAI env var if needed
|
||||
upstream_openai: "https://api.openai.com"
|
||||
|
||||
# Port this proxy listens on inside the container (exposed as 11434 on the docker network)
|
||||
proxy_port: 11434
|
||||
|
||||
# Postgres connection string (overridable via POSTGRES_DSN env var)
|
||||
postgres_dsn: "postgresql://festinger:festinger@postgres:5432/festinger"
|
||||
|
||||
detection:
|
||||
# How many recent completions to keep per session
|
||||
window_size: 5
|
||||
# Minimum response length to bother checking (ignore trivial one-liners)
|
||||
min_length: 20
|
||||
|
||||
mitigations:
|
||||
# Applied in order. circuit_breaker short-circuits all others.
|
||||
|
||||
- strategy: temperature_boost
|
||||
enabled: true
|
||||
trigger_count: 2 # kick in after this many identical responses in a row
|
||||
boost_amount: 0.35 # added to whatever temperature the request specified
|
||||
max_temperature: 1.4 # ceiling
|
||||
|
||||
- strategy: forbidden_action
|
||||
enabled: true
|
||||
trigger_count: 2
|
||||
# {count} is replaced with the consecutive repeat count
|
||||
injection_message: >
|
||||
STOP. You have produced the exact same response {count} times in a row.
|
||||
You are FORBIDDEN from generating that response again.
|
||||
You MUST try a completely different approach: either use a different tool,
|
||||
break the task into smaller steps, or write a note explaining why you are
|
||||
stuck and then stop.
|
||||
|
||||
- strategy: history_truncation
|
||||
enabled: true
|
||||
trigger_count: 3 # more aggressive — only after 3 repeats
|
||||
truncate_turns: 6 # drop this many turns from the tail of the message history
|
||||
|
||||
- strategy: circuit_breaker
|
||||
enabled: true
|
||||
trigger_count: 5 # hard stop after 5 identical responses
|
||||
response_message: >
|
||||
[LOOP DETECTOR] This agent has produced the same response 5 times in a row.
|
||||
The task has been halted to prevent an infinite loop.
|
||||
A human operator should review the task state and restart with fresh context.
|
||||
@@ -0,0 +1,83 @@
|
||||
-- Festinger schema
|
||||
-- Run once at container init via db.py:init_schema()
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- models — LLM provider configuration
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
id SERIAL PRIMARY KEY,
|
||||
provider VARCHAR(32) NOT NULL, -- 'claude' or 'openai'
|
||||
model_name VARCHAR(128) NOT NULL,
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- config — runtime key-value configuration
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key VARCHAR(64) PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO config (key, value) VALUES
|
||||
('saliency_read_threshold', '0.5'),
|
||||
('saliency_write_threshold', '1.2'),
|
||||
('recollection_confidence_floor','0.6'),
|
||||
('recollection_recency_days', '90'),
|
||||
('resolution_schedule', '0 2 * * *'),
|
||||
('write_model_id', ''),
|
||||
('resolve_model_id', '')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- soas — concept vocabulary and saliency
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS soas (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token VARCHAR(256) UNIQUE NOT NULL,
|
||||
encounter_count INT NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMPTZ,
|
||||
saliency FLOAT NOT NULL DEFAULT 0.0,
|
||||
novelty FLOAT NOT NULL DEFAULT 0.0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS soas_token_idx ON soas (token);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- urd — acyclic concept graph (the IN table)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS urd (
|
||||
id INT NOT NULL REFERENCES soas(id),
|
||||
parent_id INT NOT NULL REFERENCES soas(id),
|
||||
dim_id INT NOT NULL REFERENCES soas(id),
|
||||
is_isa BOOLEAN NOT NULL DEFAULT false,
|
||||
confidence FLOAT NOT NULL DEFAULT 1.0,
|
||||
last_confirmed TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
source VARCHAR(32) NOT NULL DEFAULT 'cloud_llm',
|
||||
PRIMARY KEY (id, parent_id, dim_id)
|
||||
);
|
||||
|
||||
-- One parent per concept per dimension — contradiction-resistance mechanism
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS urd_concept_dim_idx ON urd (id, dim_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- resolution_queue — pending conflicts waiting for nightly resolution
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS resolution_queue (
|
||||
id SERIAL PRIMARY KEY,
|
||||
concept_id INT NOT NULL REFERENCES soas(id),
|
||||
existing_parent_id INT NOT NULL REFERENCES soas(id),
|
||||
incoming_parent_id INT NOT NULL REFERENCES soas(id),
|
||||
dim_id INT NOT NULL REFERENCES soas(id),
|
||||
collision_type VARCHAR(32) NOT NULL, -- 'isa_isa', 'ispart_ispart', 'misclassification'
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
resolution JSONB,
|
||||
priority BOOLEAN NOT NULL DEFAULT false, -- gutask conflicts reviewed first
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS rq_status_idx ON resolution_queue (status);
|
||||
CREATE INDEX IF NOT EXISTS rq_concept_idx ON resolution_queue (concept_id);
|
||||
@@ -0,0 +1 @@
|
||||
# Festinger — Ollama-compatible inference middleware for Agent0
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
In-memory cache layer.
|
||||
|
||||
All read operations on SOAS and URD hit these structures only — zero network
|
||||
on the hot path. Writes are write-through: in-memory first, then Postgres.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class SoasRow:
|
||||
id: int
|
||||
token: str
|
||||
encounter_count: int = 0
|
||||
saliency: float = 0.0
|
||||
novelty: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class UrdEdge:
|
||||
concept_id: int
|
||||
parent_id: int
|
||||
dim_id: int
|
||||
is_isa: bool
|
||||
confidence: float
|
||||
source: str
|
||||
parent_token: str = ""
|
||||
dim_token: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level cache dicts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# SOAS — primary lookup by token (mirrors UNIQUE index on token)
|
||||
soas_by_token: dict[str, SoasRow] = {}
|
||||
|
||||
# SOAS — reverse lookup by id
|
||||
soas_by_id: dict[int, str] = {}
|
||||
|
||||
# URD — recollection reads: concept_id → list of edges (tokens pre-joined)
|
||||
urd_by_concept: dict[int, list[UrdEdge]] = {}
|
||||
|
||||
# URD — collision detection: (concept_id, dim_id) → edge
|
||||
# Mirrors Postgres UNIQUE index on (id, dim_id) exactly
|
||||
urd_by_concept_dim: dict[tuple[int, int], UrdEdge] = {}
|
||||
|
||||
# Concepts with pending items in resolution_queue
|
||||
pending_conflicts: set[int] = set()
|
||||
|
||||
# Batched encounter count deltas — flushed to Postgres every 30 s
|
||||
_encounter_deltas: dict[int, int] = {}
|
||||
|
||||
|
||||
def record_encounter(soas_id: int) -> None:
|
||||
"""Increment in-memory encounter count and stage a flush delta."""
|
||||
_encounter_deltas[soas_id] = _encounter_deltas.get(soas_id, 0) + 1
|
||||
if soas_id in {row.id for row in soas_by_token.values()}:
|
||||
token = soas_by_id.get(soas_id)
|
||||
if token and token in soas_by_token:
|
||||
soas_by_token[token].encounter_count += 1
|
||||
|
||||
|
||||
def drain_deltas() -> dict[int, int]:
|
||||
"""Return and clear the accumulated encounter deltas for Postgres flush."""
|
||||
global _encounter_deltas
|
||||
batch = dict(_encounter_deltas)
|
||||
_encounter_deltas = {}
|
||||
return batch
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Relationship cue scanner.
|
||||
|
||||
Scans intercepted text for explicit ISA / ISPART natural language patterns.
|
||||
When matched, extracts a (subject, parent, dimension, is_isa) triple that
|
||||
bypasses the saliency write threshold and goes directly to the write queue.
|
||||
|
||||
The `of {Z}` modifier after an ISA pattern names the dimension explicitly.
|
||||
Without it, ISA defaults to dimension 'type'; ISPART defaults to 'membership'.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class CueTriple:
|
||||
subject: str # canonical token (lowercase, compound rule applied)
|
||||
parent: str # canonical token
|
||||
dimension: str # canonical token, defaults to 'type' or 'membership'
|
||||
is_isa: bool
|
||||
confidence: float # pattern-type confidence
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pattern definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Each entry: (regex pattern, is_isa, base_confidence)
|
||||
# Groups: (?P<subj>...) (?P<parent>...) optional (?P<dim>...)
|
||||
_RAW_PATTERNS: list[tuple[str, bool, float]] = [
|
||||
# ISA — explicit keyword forms (higher confidence)
|
||||
(r"(?P<subj>\S+)\s+ISA\s+(?P<parent>\S+)", True, 0.95),
|
||||
(r"(?P<subj>\S+)\s+is\s+an?\s+instance\s+of\s+(?P<parent>\S+)", True, 0.90),
|
||||
(r"(?P<subj>\S+)\s+is\s+a\s+kind\s+of\s+(?P<parent>\S+)", True, 0.90),
|
||||
(r"(?P<subj>\S+)\s+is\s+a\s+type\s+of\s+(?P<parent>\S+)", True, 0.90),
|
||||
(r"(?P<subj>\S+)\s+kind\s+of\s+(?P<parent>\S+)", True, 0.80),
|
||||
(r"(?P<subj>\S+)\s+type\s+of\s+(?P<parent>\S+)", True, 0.80),
|
||||
(r"(?P<subj>\S+)\s+instance\s+of\s+(?P<parent>\S+)", True, 0.80),
|
||||
# ISA — "is a / is an" (most common, must handle "of Z" modifier)
|
||||
(r"(?P<subj>\S+)\s+is\s+an?\s+(?P<parent>\S+)", True, 0.85),
|
||||
# ISPART — explicit keyword forms
|
||||
(r"(?P<subj>\S+)\s+ISPART\s+(?P<parent>\S+)", False, 0.95),
|
||||
(r"(?P<subj>\S+)\s+is\s+part\s+of\s+(?P<parent>\S+)", False, 0.90),
|
||||
(r"(?P<subj>\S+)\s+is\s+a\s+member\s+of\s+(?P<parent>\S+)", False, 0.90),
|
||||
(r"(?P<subj>\S+)\s+is\s+owned\s+by\s+(?P<parent>\S+)", False, 0.90),
|
||||
(r"(?P<subj>\S+)\s+belongs\s+to\s+(?P<parent>\S+)", False, 0.85),
|
||||
(r"(?P<subj>\S+)\s+part\s+of\s+(?P<parent>\S+)", False, 0.80),
|
||||
(r"(?P<subj>\S+)\s+member\s+of\s+(?P<parent>\S+)", False, 0.80),
|
||||
(r"(?P<subj>\S+)\s+owned\s+by\s+(?P<parent>\S+)", False, 0.80),
|
||||
(r"(?P<subj>\S+)\s+runs\s+on\s+(?P<parent>\S+)", False, 0.85),
|
||||
(r"(?P<subj>\S+)\s+hosted\s+by\s+(?P<parent>\S+)", False, 0.85),
|
||||
(r"(?P<subj>\S+)\s+deployed\s+on\s+(?P<parent>\S+)", False, 0.85),
|
||||
(r"(?P<subj>\S+)\s+contained\s+in\s+(?P<parent>\S+)", False, 0.85),
|
||||
]
|
||||
|
||||
# Compiled patterns + metadata
|
||||
_PATTERNS: list[tuple[re.Pattern, bool, float]] = [
|
||||
(re.compile(p, re.IGNORECASE), is_isa, conf)
|
||||
for p, is_isa, conf in _RAW_PATTERNS
|
||||
]
|
||||
|
||||
# "of {Z}" dimension modifier following an ISA match (optional).
|
||||
# Captures the first word only; _extend_compound() handles multi-word.
|
||||
_OF_DIM_RE = re.compile(r"\s+of\s+(?P<dim>\S+)", re.IGNORECASE)
|
||||
|
||||
# Consecutive capital-starting words after a position
|
||||
_CAPITAL_WORD_RE = re.compile(r"\s+([A-Z]\w*)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token canonicalisation + compound extension
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _canonicalize(word: str) -> str:
|
||||
"""Strip punctuation, apply lowercase. Single word — no compound rule."""
|
||||
return re.sub(r"^[^\w]+|[^\w]+$", "", word).lower()
|
||||
|
||||
|
||||
def _extend_compound(text: str, after: int, first_word: str) -> str:
|
||||
"""
|
||||
Starting from position `after` in `text`, consume any immediately-following
|
||||
capital-starting words and merge them with `first_word` into a single
|
||||
underscore-joined lowercase compound token.
|
||||
|
||||
Example:
|
||||
text = "gnommoweb is part of Glitch University today"
|
||||
after = position after "Glitch"
|
||||
first_word = "Glitch"
|
||||
→ returns "glitch_university"
|
||||
"""
|
||||
words = [_canonicalize(first_word)]
|
||||
pos = after
|
||||
while True:
|
||||
m = _CAPITAL_WORD_RE.match(text, pos)
|
||||
if not m:
|
||||
break
|
||||
words.append(_canonicalize(m.group(1)))
|
||||
pos = m.end()
|
||||
return "_".join(words)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main scanner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scan_cues(text: str) -> list[CueTriple]:
|
||||
"""
|
||||
Scan *text* for relationship cue patterns. Returns all matched triples.
|
||||
Deduplicates by (subject, parent, dimension, is_isa).
|
||||
Multi-word proper-noun parents and dimensions are merged via _extend_compound().
|
||||
"""
|
||||
results: list[CueTriple] = []
|
||||
seen: set[tuple] = set()
|
||||
|
||||
for pattern, is_isa, base_conf in _PATTERNS:
|
||||
for m in pattern.finditer(text):
|
||||
raw_subj = m.group("subj")
|
||||
raw_parent = m.group("parent")
|
||||
|
||||
if not raw_subj or not raw_parent:
|
||||
continue
|
||||
|
||||
subj = _canonicalize(raw_subj)
|
||||
# Extend parent into compound if followed by more capital words
|
||||
parent = _extend_compound(text, m.end("parent"), raw_parent)
|
||||
|
||||
if not subj or not parent:
|
||||
continue
|
||||
|
||||
# Check for "of {Z}" dimension modifier immediately after the match
|
||||
dimension: str
|
||||
if is_isa:
|
||||
suffix_start = m.end()
|
||||
of_match = _OF_DIM_RE.match(text, suffix_start)
|
||||
if of_match:
|
||||
raw_dim = of_match.group("dim")
|
||||
dimension = _extend_compound(text, of_match.end("dim"), raw_dim)
|
||||
else:
|
||||
dimension = "type"
|
||||
else:
|
||||
dimension = _infer_ispart_dimension(m.re.pattern)
|
||||
|
||||
key = (subj, parent, dimension, is_isa)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append(CueTriple(
|
||||
subject=subj,
|
||||
parent=parent,
|
||||
dimension=dimension,
|
||||
is_isa=is_isa,
|
||||
confidence=base_conf,
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _infer_ispart_dimension(pattern_str: str) -> str:
|
||||
"""Guess a sensible default dimension from the ISPART pattern text."""
|
||||
if "runs" in pattern_str or "deployed" in pattern_str:
|
||||
return "runs-on"
|
||||
if "hosted" in pattern_str:
|
||||
return "runs-on"
|
||||
if "owned" in pattern_str:
|
||||
return "owned-by"
|
||||
if "part" in pattern_str or "contained" in pattern_str:
|
||||
return "membership"
|
||||
if "member" in pattern_str or "belongs" in pattern_str:
|
||||
return "membership"
|
||||
return "membership"
|
||||
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Database layer — asyncpg pool, schema init, cache warm-up, flush.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg
|
||||
|
||||
from . import cache
|
||||
from .cache import SoasRow, UrdEdge
|
||||
|
||||
log = logging.getLogger("festinger.db")
|
||||
|
||||
_pool: asyncpg.Pool | None = None
|
||||
|
||||
SCHEMA_PATH = Path(__file__).parent.parent / "db" / "schema.sql"
|
||||
|
||||
SEED_DIMENSIONS = [
|
||||
("type", True),
|
||||
("membership", False),
|
||||
("runs-on", False),
|
||||
("tech", False),
|
||||
("owned-by", False),
|
||||
("geography", False),
|
||||
]
|
||||
|
||||
|
||||
async def get_pool(dsn: str) -> asyncpg.Pool:
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = await asyncpg.create_pool(dsn, min_size=2, max_size=10)
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_pool() -> None:
|
||||
global _pool
|
||||
if _pool:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema init
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def init_schema(pool: asyncpg.Pool) -> None:
|
||||
sql = SCHEMA_PATH.read_text()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(sql)
|
||||
log.info("schema applied")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrap helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def bootstrap_dimensions(pool: asyncpg.Pool) -> None:
|
||||
"""Ensure the 6 seed dimensions exist in SOAS and have self-referential root nodes."""
|
||||
async with pool.acquire() as conn:
|
||||
for token, _is_isa in SEED_DIMENSIONS:
|
||||
row = await conn.fetchrow(
|
||||
"INSERT INTO soas (token, saliency, novelty) VALUES ($1, 0, 0) "
|
||||
"ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token "
|
||||
"RETURNING id, token, encounter_count, saliency, novelty",
|
||||
token,
|
||||
)
|
||||
dim_id = row["id"]
|
||||
# Self-referential root node: id = parent_id = dim_id
|
||||
await conn.execute(
|
||||
"INSERT INTO urd (id, parent_id, dim_id, is_isa, confidence, source) "
|
||||
"VALUES ($1, $1, $1, $2, 1.0, 'festinger') "
|
||||
"ON CONFLICT DO NOTHING",
|
||||
dim_id, True,
|
||||
)
|
||||
log.info("seed dimensions bootstrapped")
|
||||
|
||||
|
||||
async def bootstrap_english_dictionary(pool: asyncpg.Pool) -> None:
|
||||
"""
|
||||
Bulk-load /usr/share/dict/words into SOAS at saliency=0, novelty=0.
|
||||
Skips tokens already present. Only loads tokens >= 5 chars.
|
||||
"""
|
||||
dict_file = Path("/usr/share/dict/words")
|
||||
if not dict_file.exists():
|
||||
log.warning("no system dictionary found at %s — skipping bootstrap", dict_file)
|
||||
return
|
||||
|
||||
words = [
|
||||
w.strip().lower()
|
||||
for w in dict_file.read_text().splitlines()
|
||||
if len(w.strip()) >= 5 and w.strip().isalpha()
|
||||
]
|
||||
# Deduplicate
|
||||
words = list(set(words))
|
||||
log.info("loading %d dictionary words into soas …", len(words))
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Use executemany with ON CONFLICT DO NOTHING for speed
|
||||
await conn.executemany(
|
||||
"INSERT INTO soas (token, saliency, novelty) VALUES ($1, 0, 0) "
|
||||
"ON CONFLICT (token) DO NOTHING",
|
||||
[(w,) for w in words],
|
||||
)
|
||||
log.info("dictionary bootstrap complete")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache warm-up
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def warm_cache(pool: asyncpg.Pool) -> None:
|
||||
"""Load all SOAS and URD rows into the in-memory cache."""
|
||||
async with pool.acquire() as conn:
|
||||
soas_rows = await conn.fetch(
|
||||
"SELECT id, token, encounter_count, saliency, novelty FROM soas"
|
||||
)
|
||||
for r in soas_rows:
|
||||
row = SoasRow(
|
||||
id=r["id"],
|
||||
token=r["token"],
|
||||
encounter_count=r["encounter_count"],
|
||||
saliency=r["saliency"],
|
||||
novelty=r["novelty"],
|
||||
)
|
||||
cache.soas_by_token[r["token"]] = row
|
||||
cache.soas_by_id[r["id"]] = r["token"]
|
||||
|
||||
urd_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT u.id, u.parent_id, u.dim_id, u.is_isa, u.confidence, u.source,
|
||||
p.token AS parent_token, d.token AS dim_token
|
||||
FROM urd u
|
||||
INNER JOIN soas p ON p.id = u.parent_id
|
||||
INNER JOIN soas d ON d.id = u.dim_id
|
||||
-- skip self-referential dimension root nodes
|
||||
WHERE NOT (u.id = u.parent_id AND u.id = u.dim_id)
|
||||
"""
|
||||
)
|
||||
for r in urd_rows:
|
||||
edge = UrdEdge(
|
||||
concept_id=r["id"],
|
||||
parent_id=r["parent_id"],
|
||||
dim_id=r["dim_id"],
|
||||
is_isa=r["is_isa"],
|
||||
confidence=r["confidence"],
|
||||
source=r["source"],
|
||||
parent_token=r["parent_token"],
|
||||
dim_token=r["dim_token"],
|
||||
)
|
||||
cache.urd_by_concept.setdefault(r["id"], []).append(edge)
|
||||
cache.urd_by_concept_dim[(r["id"], r["dim_id"])] = edge
|
||||
|
||||
pending = await conn.fetch(
|
||||
"SELECT DISTINCT concept_id FROM resolution_queue WHERE status = 'pending'"
|
||||
)
|
||||
cache.pending_conflicts = {r["concept_id"] for r in pending}
|
||||
|
||||
log.info(
|
||||
"cache warm: %d soas, %d urd edges, %d pending conflicts",
|
||||
len(cache.soas_by_token),
|
||||
len(cache.urd_by_concept_dim),
|
||||
len(cache.pending_conflicts),
|
||||
)
|
||||
|
||||
|
||||
async def reload_urd_cache(pool: asyncpg.Pool) -> None:
|
||||
"""Rebuild only URD cache (called after nightly resolution job)."""
|
||||
cache.urd_by_concept.clear()
|
||||
cache.urd_by_concept_dim.clear()
|
||||
async with pool.acquire() as conn:
|
||||
urd_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT u.id, u.parent_id, u.dim_id, u.is_isa, u.confidence, u.source,
|
||||
p.token AS parent_token, d.token AS dim_token
|
||||
FROM urd u
|
||||
INNER JOIN soas p ON p.id = u.parent_id
|
||||
INNER JOIN soas d ON d.id = u.dim_id
|
||||
WHERE NOT (u.id = u.parent_id AND u.id = u.dim_id)
|
||||
"""
|
||||
)
|
||||
for r in urd_rows:
|
||||
edge = UrdEdge(
|
||||
concept_id=r["id"],
|
||||
parent_id=r["parent_id"],
|
||||
dim_id=r["dim_id"],
|
||||
is_isa=r["is_isa"],
|
||||
confidence=r["confidence"],
|
||||
source=r["source"],
|
||||
parent_token=r["parent_token"],
|
||||
dim_token=r["dim_token"],
|
||||
)
|
||||
cache.urd_by_concept.setdefault(r["id"], []).append(edge)
|
||||
cache.urd_by_concept_dim[(r["id"], r["dim_id"])] = edge
|
||||
|
||||
pending = await conn.fetch(
|
||||
"SELECT DISTINCT concept_id FROM resolution_queue WHERE status = 'pending'"
|
||||
)
|
||||
cache.pending_conflicts = {r["concept_id"] for r in pending}
|
||||
log.info("urd cache reloaded")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SOAS upsert — id always comes from Postgres
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_or_create_soas(pool: asyncpg.Pool, token: str) -> SoasRow:
|
||||
"""Return cached SoasRow, inserting into Postgres + cache if new."""
|
||||
if token in cache.soas_by_token:
|
||||
return cache.soas_by_token[token]
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"INSERT INTO soas (token) VALUES ($1) "
|
||||
"ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token "
|
||||
"RETURNING id, token, encounter_count, saliency, novelty",
|
||||
token,
|
||||
)
|
||||
|
||||
soas_row = SoasRow(
|
||||
id=row["id"],
|
||||
token=row["token"],
|
||||
encounter_count=row["encounter_count"],
|
||||
saliency=row["saliency"],
|
||||
novelty=row["novelty"],
|
||||
)
|
||||
cache.soas_by_token[token] = soas_row
|
||||
cache.soas_by_id[row["id"]] = token
|
||||
return soas_row
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Saliency recalculation (log-scale)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def recalculate_saliency(encounter_count: int, is_common_english: bool) -> float:
|
||||
"""
|
||||
Log-scaled saliency. Common English words are pre-seeded with count=0,
|
||||
novelty=0 and will always return 0. Domain tokens start at count=1 after
|
||||
first encounter; saliency grows logarithmically.
|
||||
"""
|
||||
if is_common_english or encounter_count <= 0:
|
||||
return 0.0
|
||||
return math.log1p(encounter_count)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch saliency flush
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def flush_encounter_deltas(pool: asyncpg.Pool) -> None:
|
||||
"""Flush staged encounter_count deltas to Postgres in one batch UPDATE."""
|
||||
deltas = cache.drain_deltas()
|
||||
if not deltas:
|
||||
return
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
for soas_id, delta in deltas.items():
|
||||
token = cache.soas_by_id.get(soas_id, "")
|
||||
row = cache.soas_by_token.get(token)
|
||||
new_count = (row.encounter_count if row else 0)
|
||||
# novelty = 0 for common English words (pre-seeded)
|
||||
is_common = (row.novelty == 0.0 and row.saliency == 0.0) if row else False
|
||||
new_saliency = recalculate_saliency(new_count, is_common)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE soas
|
||||
SET encounter_count = encounter_count + $1,
|
||||
last_seen = now(),
|
||||
saliency = $2
|
||||
WHERE id = $3
|
||||
""",
|
||||
delta, new_saliency, soas_id,
|
||||
)
|
||||
if row:
|
||||
row.saliency = new_saliency
|
||||
|
||||
log.debug("flushed %d saliency deltas", len(deltas))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_config(pool: asyncpg.Pool, key: str, default: str = "") -> str:
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("SELECT value FROM config WHERE key = $1", key)
|
||||
return row["value"] if row else default
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
LLM client — supports 'claude' and 'openai' providers.
|
||||
|
||||
Uses the model configured in the models table (fetched via write_model_id
|
||||
or resolve_model_id config keys).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
import asyncpg
|
||||
|
||||
log = logging.getLogger("festinger.llm")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelConfig:
|
||||
provider: str
|
||||
model_name: str
|
||||
api_key: str
|
||||
|
||||
|
||||
async def get_model_config(pool: asyncpg.Pool, model_id: str) -> Optional[ModelConfig]:
|
||||
if not model_id:
|
||||
return None
|
||||
try:
|
||||
mid = int(model_id)
|
||||
except ValueError:
|
||||
return None
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT provider, model_name, api_key FROM models WHERE id=$1", mid
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
return ModelConfig(
|
||||
provider=row["provider"],
|
||||
model_name=row["model_name"],
|
||||
api_key=row["api_key"],
|
||||
)
|
||||
|
||||
|
||||
async def call_llm(model: ModelConfig, prompt: str) -> str:
|
||||
"""Call the configured LLM and return the text response."""
|
||||
if model.provider == "claude":
|
||||
return await _call_claude(model, prompt)
|
||||
elif model.provider == "openai":
|
||||
return await _call_openai(model, prompt)
|
||||
else:
|
||||
raise ValueError(f"Unknown LLM provider: {model.provider!r}")
|
||||
|
||||
|
||||
async def _call_claude(model: ModelConfig, prompt: str) -> str:
|
||||
import anthropic
|
||||
client = anthropic.AsyncAnthropic(api_key=model.api_key)
|
||||
message = await client.messages.create(
|
||||
model=model.model_name,
|
||||
max_tokens=1024,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return message.content[0].text
|
||||
|
||||
|
||||
async def _call_openai(model: ModelConfig, prompt: str) -> str:
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(api_key=model.api_key)
|
||||
response = await client.chat.completions.create(
|
||||
model=model.model_name,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=1024,
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structured prompts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WRITE_PROMPT_TEMPLATE = """You are a knowledge extraction assistant.
|
||||
|
||||
Given the concept: "{concept}"
|
||||
|
||||
And these known dimensions: {dimensions}
|
||||
|
||||
Return a JSON array of triples describing what you know about this concept.
|
||||
Each triple must have these fields:
|
||||
- "parent": the containing concept (string)
|
||||
- "dimension": one of the known dimensions above, or a new specific one if none fit
|
||||
- "is_isa": true if this is a classification (ISA), false if membership/containment (ISPART)
|
||||
- "confidence": 0.0 to 1.0
|
||||
|
||||
Return ONLY the JSON array. No explanation. Example:
|
||||
[
|
||||
{{"parent": "software-repository", "dimension": "type", "is_isa": true, "confidence": 0.9}},
|
||||
{{"parent": "glitch-university", "dimension": "membership", "is_isa": false, "confidence": 0.85}}
|
||||
]
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMTriple:
|
||||
parent: str
|
||||
dimension: str
|
||||
is_isa: bool
|
||||
confidence: float
|
||||
|
||||
|
||||
def parse_llm_triples(response: str) -> list[LLMTriple]:
|
||||
"""Parse JSON array of triples from LLM response."""
|
||||
try:
|
||||
# Find the JSON array in the response
|
||||
start = response.find("[")
|
||||
end = response.rfind("]") + 1
|
||||
if start == -1 or end == 0:
|
||||
return []
|
||||
data = json.loads(response[start:end])
|
||||
triples = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
triples.append(LLMTriple(
|
||||
parent=str(item.get("parent", "")).strip().lower(),
|
||||
dimension=str(item.get("dimension", "type")).strip().lower(),
|
||||
is_isa=bool(item.get("is_isa", True)),
|
||||
confidence=float(item.get("confidence", 0.7)),
|
||||
))
|
||||
return triples
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
log.warning("failed to parse LLM triples: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
RESOLVE_ISA_ISA_PROMPT = """You are a knowledge graph resolution assistant.
|
||||
|
||||
Concept: "{concept}"
|
||||
Current fact: "{concept}" is a "{existing_parent}" in dimension "{dimension}"
|
||||
Conflicting fact: "{concept}" is a "{incoming_parent}" in dimension "{dimension}"
|
||||
|
||||
Both facts appear to be simultaneously true. The dimension "{dimension}" is too coarse.
|
||||
Propose two more specific dimension names to replace it:
|
||||
- existing_dimension: the dimension where "{concept}" as "{existing_parent}" is valid
|
||||
- new_dimension: the dimension where "{concept}" as "{incoming_parent}" is valid
|
||||
|
||||
Known dimensions for reference: {known_dimensions}
|
||||
|
||||
Return ONLY a JSON object:
|
||||
{{"decision": "decompose", "existing_dimension": "...", "new_dimension": "...", "reasoning": "..."}}
|
||||
|
||||
If the conflicting fact is clearly wrong, return:
|
||||
{{"decision": "dismiss", "reasoning": "..."}}
|
||||
"""
|
||||
|
||||
RESOLVE_ISPART_ISPART_PROMPT = """You are a knowledge graph resolution assistant.
|
||||
|
||||
Concept: "{concept}"
|
||||
Current fact: "{concept}" belongs to "{existing_parent}" in dimension "{dimension}"
|
||||
Conflicting fact: "{concept}" belongs to "{incoming_parent}" in dimension "{dimension}"
|
||||
|
||||
These facts contradict each other — a thing can only belong to one place per dimension.
|
||||
|
||||
Return ONLY a JSON object with your decision:
|
||||
{{"decision": "update", "reasoning": "..."}} — if the incoming fact is more current/correct
|
||||
{{"decision": "dismiss", "reasoning": "..."}} — if the existing fact is still correct
|
||||
"""
|
||||
|
||||
RESOLVE_MISCLASS_PROMPT = """You are a knowledge graph resolution assistant.
|
||||
|
||||
Concept: "{concept}"
|
||||
A relationship was classified inconsistently:
|
||||
- Existing: "{concept}" ISA "{existing_parent}" in dimension "{dimension}"
|
||||
- Incoming: "{concept}" ISPART "{incoming_parent}" in dimension "{dimension}"
|
||||
|
||||
The incoming fact uses a different relation type. Suggest the correct dimension for the incoming fact.
|
||||
|
||||
Known dimensions: {known_dimensions}
|
||||
|
||||
Return ONLY a JSON object:
|
||||
{{"decision": "reclassify", "correct_dimension": "...", "reasoning": "..."}}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolutionDecision:
|
||||
decision: str # 'decompose', 'update', 'dismiss', 'reclassify'
|
||||
existing_dimension: str = ""
|
||||
new_dimension: str = ""
|
||||
correct_dimension: str = ""
|
||||
reasoning: str = ""
|
||||
|
||||
|
||||
def parse_resolution_decision(response: str) -> Optional[ResolutionDecision]:
|
||||
try:
|
||||
start = response.find("{")
|
||||
end = response.rfind("}") + 1
|
||||
if start == -1 or end == 0:
|
||||
return None
|
||||
data = json.loads(response[start:end])
|
||||
return ResolutionDecision(
|
||||
decision=str(data.get("decision", "dismiss")).lower(),
|
||||
existing_dimension=str(data.get("existing_dimension", "")).lower(),
|
||||
new_dimension=str(data.get("new_dimension", "")).lower(),
|
||||
correct_dimension=str(data.get("correct_dimension", "")).lower(),
|
||||
reasoning=str(data.get("reasoning", "")),
|
||||
)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
log.warning("failed to parse resolution decision: %s", e)
|
||||
return None
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Loop detector — extracted from the original proxy.py POC.
|
||||
|
||||
Session-scoped exact-match repetition detection with configurable mitigations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict, deque
|
||||
|
||||
log = logging.getLogger("festinger.loop")
|
||||
|
||||
# Key: (model, session_fingerprint) → deque of response hashes
|
||||
_history: dict[tuple, deque] = defaultdict(lambda: deque(maxlen=20))
|
||||
_consecutive: dict[tuple, int] = defaultdict(int)
|
||||
_last_hash: dict[tuple, str] = {}
|
||||
|
||||
|
||||
def session_key(model: str, messages: list[dict]) -> tuple[str, str]:
|
||||
system_msgs = [m for m in messages if m.get("role") == "system"]
|
||||
if system_msgs:
|
||||
fingerprint = hashlib.sha256(
|
||||
system_msgs[0].get("content", "")[:512].encode()
|
||||
).hexdigest()[:16]
|
||||
else:
|
||||
non_assistant = [m for m in messages if m.get("role") != "assistant"]
|
||||
blob = json.dumps(non_assistant, sort_keys=True)
|
||||
fingerprint = hashlib.sha256(blob.encode()).hexdigest()[:16]
|
||||
return (model, fingerprint)
|
||||
|
||||
|
||||
def hash_response(text: str) -> str:
|
||||
return hashlib.sha256(text.strip().encode()).hexdigest()
|
||||
|
||||
|
||||
def record_and_check(session: tuple, text: str, min_length: int) -> int:
|
||||
if len(text.strip()) < min_length:
|
||||
return 1
|
||||
h = hash_response(text)
|
||||
if _last_hash.get(session) == h:
|
||||
_consecutive[session] += 1
|
||||
else:
|
||||
_consecutive[session] = 1
|
||||
_last_hash[session] = h
|
||||
_history[session].append(h)
|
||||
return _consecutive[session]
|
||||
|
||||
|
||||
def apply_mitigations(
|
||||
request_body: dict,
|
||||
count: int,
|
||||
config: dict,
|
||||
) -> tuple[dict, str | None]:
|
||||
mitigations = config.get("mitigations", [])
|
||||
override: str | None = None
|
||||
|
||||
for m in mitigations:
|
||||
if not m.get("enabled", True):
|
||||
continue
|
||||
if count < m.get("trigger_count", 2):
|
||||
continue
|
||||
|
||||
strategy = m["strategy"]
|
||||
|
||||
if strategy == "temperature_boost":
|
||||
opts = request_body.setdefault("options", {})
|
||||
current = opts.get("temperature", 0.7)
|
||||
boosted = min(current + m.get("boost_amount", 0.35), m.get("max_temperature", 1.4))
|
||||
opts["temperature"] = boosted
|
||||
log.warning("mitigation=temperature_boost %.2f → %.2f (count=%d)", current, boosted, count)
|
||||
|
||||
elif strategy == "forbidden_action":
|
||||
msg = m.get("injection_message", "STOP. Try something completely different.").format(count=count)
|
||||
request_body.setdefault("messages", []).append({"role": "user", "content": msg})
|
||||
log.warning("mitigation=forbidden_action injected (count=%d)", count)
|
||||
|
||||
elif strategy == "history_truncation":
|
||||
messages = request_body.get("messages", [])
|
||||
truncate = m.get("truncate_turns", 6)
|
||||
system_msgs = [m_ for m_ in messages if m_.get("role") == "system"]
|
||||
non_system = [m_ for m_ in messages if m_.get("role") != "system"]
|
||||
trimmed = non_system[:-truncate] if len(non_system) > truncate else non_system[-2:]
|
||||
request_body["messages"] = system_msgs + trimmed
|
||||
log.warning("mitigation=history_truncation dropped %d turns (count=%d)", truncate, count)
|
||||
|
||||
elif strategy == "circuit_breaker":
|
||||
override = m.get("response_message", "[LOOP DETECTOR] Halted due to repeated responses.")
|
||||
log.error("mitigation=circuit_breaker TRIGGERED (count=%d)", count)
|
||||
break
|
||||
|
||||
return request_body, override
|
||||
@@ -0,0 +1,821 @@
|
||||
"""
|
||||
Festinger — main FastAPI application.
|
||||
|
||||
Routes:
|
||||
POST /api/chat Ollama-compatible chat (loop detection + recollection injection)
|
||||
POST /api/generate Ollama-compatible generate
|
||||
POST /v1/messages Anthropic Messages API proxy (loop detection + recollection)
|
||||
POST /v1/chat/completions OpenAI-compatible proxy (loop detection + recollection)
|
||||
POST /iknowthat Manual write path (gutask iknowthat)
|
||||
POST /resolve/run Manually trigger nightly resolution job
|
||||
POST /reload Reload URD cache (called by resolution job)
|
||||
GET /health Health + stats
|
||||
GET /conflicts Pending and recently resolved conflicts
|
||||
GET /admin Minimal admin UI
|
||||
* /v1/{path} Passthrough to upstream Anthropic
|
||||
* /{path} Passthrough to upstream Ollama
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from . import cache
|
||||
from .db import (
|
||||
close_pool, get_config, get_or_create_soas,
|
||||
get_pool, init_schema, bootstrap_dimensions,
|
||||
bootstrap_english_dictionary, warm_cache, reload_urd_cache,
|
||||
flush_encounter_deltas,
|
||||
)
|
||||
from .loop_detector import apply_mitigations, record_and_check, session_key
|
||||
from .cue_scanner import scan_cues
|
||||
from .recollection import build_recollection_block, inject_recollection
|
||||
from .resolution_job import run_resolution_job, last_run_timestamp
|
||||
from .tokenizer import tokenize
|
||||
from .write_queue import enqueue_concept, enqueue_cue, start_worker, stop_worker
|
||||
from .urd_writer import InsertRequest, insert_urd_edge
|
||||
from .wordnet import import_wordnet, CITATION as WORDNET_CITATION
|
||||
from .test_scenarios import SCENARIOS, seed_scenario, reset_scenario
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(message)s",
|
||||
)
|
||||
log = logging.getLogger("festinger")
|
||||
|
||||
CONFIG_PATH = Path(__file__).parent.parent / "config.yaml"
|
||||
|
||||
|
||||
def load_yaml_config() -> dict:
|
||||
with open(CONFIG_PATH) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifespan — startup / shutdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
cfg = load_yaml_config()
|
||||
dsn = os.environ.get("POSTGRES_DSN", cfg.get("postgres_dsn", ""))
|
||||
|
||||
pool = await get_pool(dsn)
|
||||
app.state.pool = pool
|
||||
app.state.yaml_config = cfg
|
||||
|
||||
await init_schema(pool)
|
||||
await bootstrap_dimensions(pool)
|
||||
await bootstrap_english_dictionary(pool)
|
||||
await warm_cache(pool)
|
||||
await start_worker(pool)
|
||||
|
||||
# Schedule saliency flush every 30 s
|
||||
_scheduler.add_job(flush_encounter_deltas, "interval", seconds=30, args=[pool], id="saliency_flush")
|
||||
|
||||
# Schedule nightly resolution job from config table
|
||||
resolution_cron = await get_config(pool, "resolution_schedule", "0 2 * * *")
|
||||
cron_parts = resolution_cron.split()
|
||||
if len(cron_parts) == 5:
|
||||
minute, hour, day, month, dow = cron_parts
|
||||
_scheduler.add_job(
|
||||
run_resolution_job, "cron",
|
||||
minute=minute, hour=hour, day=day, month=month, day_of_week=dow,
|
||||
args=[pool], id="nightly_resolution",
|
||||
)
|
||||
|
||||
_scheduler.start()
|
||||
log.info("festinger ready")
|
||||
|
||||
yield
|
||||
|
||||
_scheduler.shutdown(wait=False)
|
||||
await stop_worker()
|
||||
await close_pool()
|
||||
|
||||
|
||||
app = FastAPI(title="Festinger", lifespan=lifespan)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ollama forwarding helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def call_ollama(path: str, body: dict, upstream: str) -> tuple[str, dict]:
|
||||
body = dict(body)
|
||||
body["stream"] = False
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
r = await client.post(f"{upstream}{path}", json=body)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if path == "/api/chat":
|
||||
text = data.get("message", {}).get("content", "")
|
||||
else:
|
||||
text = data.get("response", "")
|
||||
return text, data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Anthropic forwarding helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _relay_headers(request: Request, keep: tuple[str, ...]) -> dict[str, str]:
|
||||
"""Extract a safe subset of request headers to forward upstream."""
|
||||
return {
|
||||
k: v for k, v in request.headers.items()
|
||||
if k.lower() in keep
|
||||
}
|
||||
|
||||
|
||||
ANTHROPIC_RELAY_HEADERS = (
|
||||
"x-api-key",
|
||||
"anthropic-version",
|
||||
"anthropic-beta",
|
||||
"content-type",
|
||||
)
|
||||
|
||||
OPENAI_RELAY_HEADERS = (
|
||||
"authorization",
|
||||
"content-type",
|
||||
"openai-organization",
|
||||
"openai-project",
|
||||
)
|
||||
|
||||
|
||||
async def call_anthropic(body: dict, upstream: str, headers: dict) -> tuple[str, dict]:
|
||||
"""
|
||||
Forward a request to the Anthropic Messages API (non-streaming).
|
||||
Returns (assistant_text, raw_response_dict).
|
||||
"""
|
||||
body = dict(body)
|
||||
body["stream"] = False
|
||||
# Anthropic requires anthropic-version header; add default if caller omitted it
|
||||
if "anthropic-version" not in {k.lower() for k in headers}:
|
||||
headers = {**headers, "anthropic-version": "2023-06-01"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
r = await client.post(
|
||||
f"{upstream}/v1/messages",
|
||||
json=body,
|
||||
headers=headers,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
# Extract text from Anthropic content blocks
|
||||
text = ""
|
||||
for block in data.get("content", []):
|
||||
if block.get("type") == "text":
|
||||
text += block.get("text", "")
|
||||
return text, data
|
||||
|
||||
|
||||
async def call_openai(body: dict, upstream: str, headers: dict) -> tuple[str, dict]:
|
||||
"""
|
||||
Forward a request to an OpenAI-compatible chat completions endpoint (non-streaming).
|
||||
Returns (assistant_text, raw_response_dict).
|
||||
"""
|
||||
body = dict(body)
|
||||
body["stream"] = False
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
r = await client.post(
|
||||
f"{upstream}/v1/chat/completions",
|
||||
json=body,
|
||||
headers=headers,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
return text, data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text extraction helpers (unified across API formats)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_prompt_text(body: dict, path: str) -> str:
|
||||
"""Extract a flat string from a request body for saliency processing."""
|
||||
if path in ("/api/chat", "/v1/chat/completions"):
|
||||
messages = body.get("messages", [])
|
||||
parts = []
|
||||
for m in messages:
|
||||
content = m.get("content", "")
|
||||
if isinstance(content, str):
|
||||
parts.append(content)
|
||||
elif isinstance(content, list):
|
||||
# Anthropic-style content blocks
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(block.get("text", ""))
|
||||
# Include top-level system field (Anthropic format)
|
||||
if body.get("system"):
|
||||
parts.insert(0, body["system"])
|
||||
return " ".join(parts)
|
||||
if path == "/v1/messages":
|
||||
return extract_prompt_text(body, "/v1/chat/completions")
|
||||
return body.get("prompt", "")
|
||||
|
||||
|
||||
def inject_recollection_anthropic(body: dict, block: str) -> dict:
|
||||
"""
|
||||
Inject a recollection block into an Anthropic Messages API request.
|
||||
Anthropic uses a top-level 'system' string field rather than a system message.
|
||||
"""
|
||||
body = dict(body)
|
||||
existing = body.get("system") or ""
|
||||
body["system"] = block + ("\n\n" + existing if existing else "")
|
||||
return body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Saliency + recollection pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def process_prompt(body: dict, path: str, pool, cfg: dict) -> dict:
|
||||
"""
|
||||
Run the saliency + recollection pipeline over the prompt.
|
||||
Returns a (possibly modified) body dict with the recollection block injected.
|
||||
"""
|
||||
read_threshold = float(await get_config(pool, "saliency_read_threshold", "0.5"))
|
||||
write_threshold = float(await get_config(pool, "saliency_write_threshold", "1.2"))
|
||||
conf_floor = float(await get_config(pool, "recollection_confidence_floor", "0.6"))
|
||||
recency_days = int(await get_config(pool, "recollection_recency_days", "90"))
|
||||
|
||||
prompt_text = extract_prompt_text(body, path)
|
||||
if not prompt_text.strip():
|
||||
return body
|
||||
|
||||
# 1. Scan for explicit relationship cues (bypass threshold)
|
||||
cues = scan_cues(prompt_text)
|
||||
for cue in cues:
|
||||
await enqueue_cue(cue)
|
||||
|
||||
# 2. Tokenise + update saliency
|
||||
tokens = tokenize(prompt_text)
|
||||
salient_for_read: list[int] = []
|
||||
salient_for_write: list[str] = []
|
||||
|
||||
for token in tokens:
|
||||
soas_row = cache.soas_by_token.get(token)
|
||||
if soas_row is None:
|
||||
# New token — get_or_create happens in background via queue when needed
|
||||
continue # unknown token — skip saliency for now; write queue handles creation
|
||||
|
||||
cache.record_encounter(soas_row.id)
|
||||
|
||||
if soas_row.saliency >= read_threshold:
|
||||
salient_for_read.append(soas_row.id)
|
||||
|
||||
if soas_row.saliency >= write_threshold and soas_row.novelty < 1.0:
|
||||
salient_for_write.append(token)
|
||||
|
||||
for token in salient_for_write:
|
||||
await enqueue_concept(token)
|
||||
|
||||
if not salient_for_read:
|
||||
return body
|
||||
|
||||
# 3. Build recollection block
|
||||
block = build_recollection_block(salient_for_read, conf_floor, recency_days)
|
||||
if not block:
|
||||
return body
|
||||
|
||||
# 4. Inject into messages
|
||||
if path == "/api/chat" or path == "/v1/chat/completions":
|
||||
body = dict(body)
|
||||
body["messages"] = inject_recollection(body.get("messages", []), block)
|
||||
elif path == "/v1/messages":
|
||||
body = inject_recollection_anthropic(body, block)
|
||||
# /api/generate uses a flat prompt string — prepend there
|
||||
elif path == "/api/generate":
|
||||
body = dict(body)
|
||||
body["prompt"] = block + "\n\n" + body.get("prompt", "")
|
||||
|
||||
return body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def chat(request: Request) -> Response:
|
||||
cfg = request.app.state.yaml_config
|
||||
pool = request.app.state.pool
|
||||
body = await request.json()
|
||||
model = body.get("model", "unknown")
|
||||
upstream = cfg["upstream_ollama"]
|
||||
min_len = cfg["detection"]["min_length"]
|
||||
|
||||
body = await process_prompt(body, "/api/chat", pool, cfg)
|
||||
|
||||
text, raw = await call_ollama("/api/chat", body, upstream)
|
||||
sess = session_key(model, body.get("messages", []))
|
||||
count = record_and_check(sess, text, min_len)
|
||||
|
||||
if count >= 2:
|
||||
log.warning("loop detected model=%s session=%s count=%d", model, sess[1], count)
|
||||
body, override = apply_mitigations(body, count, cfg)
|
||||
if override is not None:
|
||||
raw["message"] = {"role": "assistant", "content": override}
|
||||
raw["loop_detected"] = True
|
||||
return Response(content=json.dumps(raw), media_type="application/json")
|
||||
text, raw = await call_ollama("/api/chat", body, upstream)
|
||||
record_and_check(sess, text, min_len)
|
||||
|
||||
raw["message"] = {"role": "assistant", "content": text}
|
||||
return Response(content=json.dumps(raw), media_type="application/json")
|
||||
|
||||
|
||||
@app.post("/api/generate")
|
||||
async def generate(request: Request) -> Response:
|
||||
cfg = request.app.state.yaml_config
|
||||
pool = request.app.state.pool
|
||||
body = await request.json()
|
||||
model = body.get("model", "unknown")
|
||||
upstream = cfg["upstream_ollama"]
|
||||
min_len = cfg["detection"]["min_length"]
|
||||
|
||||
body = await process_prompt(body, "/api/generate", pool, cfg)
|
||||
|
||||
messages = [{"role": "user", "content": body.get("prompt", "")}]
|
||||
sess = session_key(model, messages)
|
||||
|
||||
text, raw = await call_ollama("/api/generate", body, upstream)
|
||||
count = record_and_check(sess, text, min_len)
|
||||
|
||||
if count >= 2:
|
||||
log.warning("loop detected model=%s session=%s count=%d", model, sess[1], count)
|
||||
body, override = apply_mitigations(body, count, cfg)
|
||||
if override is not None:
|
||||
raw["response"] = override
|
||||
raw["loop_detected"] = True
|
||||
return Response(content=json.dumps(raw), media_type="application/json")
|
||||
text, raw = await call_ollama("/api/generate", body, upstream)
|
||||
record_and_check(sess, text, min_len)
|
||||
|
||||
raw["response"] = text
|
||||
return Response(content=json.dumps(raw), media_type="application/json")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Anthropic Messages API (POST /v1/messages)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/v1/messages")
|
||||
async def anthropic_messages(request: Request) -> Response:
|
||||
cfg = request.app.state.yaml_config
|
||||
pool = request.app.state.pool
|
||||
body = await request.json()
|
||||
model = body.get("model", "unknown")
|
||||
upstream = cfg["upstream_anthropic"]
|
||||
min_len = cfg["detection"]["min_length"]
|
||||
|
||||
headers = _relay_headers(request, ANTHROPIC_RELAY_HEADERS)
|
||||
# Ensure anthropic-version is present
|
||||
if "anthropic-version" not in {k.lower() for k in headers}:
|
||||
headers["anthropic-version"] = "2023-06-01"
|
||||
|
||||
body = await process_prompt(body, "/v1/messages", pool, cfg)
|
||||
|
||||
# Use messages list as session key (same logic as /api/chat)
|
||||
messages = body.get("messages", [])
|
||||
sess = session_key(model, messages)
|
||||
|
||||
text, raw = await call_anthropic(body, upstream, headers)
|
||||
count = record_and_check(sess, text, min_len)
|
||||
|
||||
if count >= 2:
|
||||
log.warning("loop detected model=%s session=%s count=%d", model, sess[1], count)
|
||||
body, override = apply_mitigations(body, count, cfg)
|
||||
if override is not None:
|
||||
# Return a minimal Anthropic-format response with the override message
|
||||
raw["content"] = [{"type": "text", "text": override}]
|
||||
raw["loop_detected"] = True
|
||||
return Response(content=json.dumps(raw), media_type="application/json")
|
||||
text, raw = await call_anthropic(body, upstream, headers)
|
||||
record_and_check(sess, text, min_len)
|
||||
|
||||
return Response(content=json.dumps(raw), media_type="application/json")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAI-compatible chat completions (POST /v1/chat/completions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
async def openai_chat_completions(request: Request) -> Response:
|
||||
cfg = request.app.state.yaml_config
|
||||
pool = request.app.state.pool
|
||||
body = await request.json()
|
||||
model = body.get("model", "unknown")
|
||||
upstream = cfg["upstream_openai"]
|
||||
min_len = cfg["detection"]["min_length"]
|
||||
|
||||
headers = _relay_headers(request, OPENAI_RELAY_HEADERS)
|
||||
|
||||
body = await process_prompt(body, "/v1/chat/completions", pool, cfg)
|
||||
|
||||
messages = body.get("messages", [])
|
||||
sess = session_key(model, messages)
|
||||
|
||||
text, raw = await call_openai(body, upstream, headers)
|
||||
count = record_and_check(sess, text, min_len)
|
||||
|
||||
if count >= 2:
|
||||
log.warning("loop detected model=%s session=%s count=%d", model, sess[1], count)
|
||||
body, override = apply_mitigations(body, count, cfg)
|
||||
if override is not None:
|
||||
if raw.get("choices"):
|
||||
raw["choices"][0]["message"]["content"] = override
|
||||
raw["loop_detected"] = True
|
||||
return Response(content=json.dumps(raw), media_type="application/json")
|
||||
text, raw = await call_openai(body, upstream, headers)
|
||||
record_and_check(sess, text, min_len)
|
||||
|
||||
return Response(content=json.dumps(raw), media_type="application/json")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /iknowthat — manual write path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/iknowthat")
|
||||
async def iknowthat(request: Request) -> dict:
|
||||
"""
|
||||
Parse and insert a manual fact.
|
||||
Body: {"fact": "gnommoweb -isa repo in context of glitch_university"}
|
||||
"""
|
||||
pool = request.app.state.pool
|
||||
data = await request.json()
|
||||
fact = data.get("fact", "").strip()
|
||||
if not fact:
|
||||
return {"error": "fact is required"}
|
||||
|
||||
# Parse: "{subject} -isa|-ispart {parent} [in context of {dimension}]"
|
||||
import re
|
||||
m = re.match(
|
||||
r"^(?P<subj>\S+)\s+(?P<rel>-isa|-ispart)\s+(?P<parent>\S+)"
|
||||
r"(?:\s+in\s+context\s+of\s+(?P<dim>\S+))?$",
|
||||
fact, re.IGNORECASE,
|
||||
)
|
||||
if not m:
|
||||
return {"error": f"could not parse fact: {fact!r}. "
|
||||
"Expected: '<subject> -isa|-ispart <parent> [in context of <dimension>]'"}
|
||||
|
||||
subj = m.group("subj").lower()
|
||||
is_isa = m.group("rel").lower() == "-isa"
|
||||
parent = m.group("parent").lower()
|
||||
dim = (m.group("dim") or ("type" if is_isa else "membership")).lower()
|
||||
|
||||
subj_row = await get_or_create_soas(pool, subj)
|
||||
parent_row = await get_or_create_soas(pool, parent)
|
||||
dim_row = await get_or_create_soas(pool, dim)
|
||||
|
||||
req = InsertRequest(
|
||||
concept_id=subj_row.id,
|
||||
parent_id=parent_row.id,
|
||||
dim_id=dim_row.id,
|
||||
is_isa=is_isa,
|
||||
confidence=1.0,
|
||||
source="gutask",
|
||||
)
|
||||
collision = await insert_urd_edge(pool, req, priority=True)
|
||||
|
||||
if collision:
|
||||
return {
|
||||
"status": "collision",
|
||||
"collision_type": collision.collision_type,
|
||||
"fact": fact,
|
||||
"message": "Conflict queued for nightly resolution (priority). "
|
||||
"Current world model unchanged.",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "inserted",
|
||||
"fact": fact,
|
||||
"subject": subj,
|
||||
"parent": parent,
|
||||
"dimension": dim,
|
||||
"is_isa": is_isa,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /resolve/run — manually trigger resolution job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/resolve/run")
|
||||
async def resolve_run(request: Request) -> dict:
|
||||
pool = request.app.state.pool
|
||||
result = await run_resolution_job(pool)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /wordnet/import — bulk-load WordNet lemmas into SOAS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/wordnet/import")
|
||||
async def wordnet_import(request: Request) -> dict:
|
||||
"""
|
||||
Import Princeton WordNet 3.x lemmas into SOAS (saliency=0, novelty=0).
|
||||
Idempotent — already-present tokens are skipped.
|
||||
Can take 5–15 seconds for a full import (~130k lemmas).
|
||||
"""
|
||||
pool = request.app.state.pool
|
||||
result = await import_wordnet(pool)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /reload — reload URD cache (used after resolution job)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/reload")
|
||||
async def reload(request: Request) -> dict:
|
||||
pool = request.app.state.pool
|
||||
await reload_urd_cache(pool)
|
||||
return {"status": "ok", "urd_edges": len(cache.urd_by_concept_dim)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/health")
|
||||
async def health(request: Request) -> dict:
|
||||
cfg = request.app.state.yaml_config
|
||||
return {
|
||||
"status": "ok",
|
||||
"upstream": cfg["upstream_ollama"],
|
||||
"active_loop_sessions": 0, # loop detector is stateful in-process
|
||||
"soas_tokens": len(cache.soas_by_token),
|
||||
"urd_edges": len(cache.urd_by_concept_dim),
|
||||
"pending_conflicts": len(cache.pending_conflicts),
|
||||
"last_resolution_run": last_run_timestamp(),
|
||||
"timestamp": int(time.time()),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /conflicts — expose resolution queue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/conflicts")
|
||||
async def conflicts(request: Request) -> dict:
|
||||
pool = request.app.state.pool
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, concept_id, existing_parent_id, incoming_parent_id,
|
||||
dim_id, collision_type, status, resolution, priority,
|
||||
created_at, resolved_at
|
||||
FROM resolution_queue
|
||||
ORDER BY status ASC, created_at DESC
|
||||
LIMIT 100
|
||||
"""
|
||||
)
|
||||
|
||||
def format_row(r):
|
||||
return {
|
||||
"id": r["id"],
|
||||
"concept": cache.soas_by_id.get(r["concept_id"], str(r["concept_id"])),
|
||||
"existing_parent": cache.soas_by_id.get(r["existing_parent_id"], "?"),
|
||||
"incoming_parent": cache.soas_by_id.get(r["incoming_parent_id"], "?"),
|
||||
"dimension": cache.soas_by_id.get(r["dim_id"], "?"),
|
||||
"collision_type": r["collision_type"],
|
||||
"status": r["status"],
|
||||
"resolution": r["resolution"],
|
||||
"priority": r["priority"],
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||
"resolved_at": r["resolved_at"].isoformat() if r["resolved_at"] else None,
|
||||
}
|
||||
|
||||
return {"conflicts": [format_row(r) for r in rows]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /test — scenario seeding and reset (for integration testing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/test/scenarios")
|
||||
async def list_scenarios() -> dict:
|
||||
"""List all available test scenarios."""
|
||||
return {
|
||||
"scenarios": {
|
||||
sid: {
|
||||
"name": sc.name,
|
||||
"trigger_fact": sc.trigger_fact,
|
||||
"expected_collision": sc.expected_collision,
|
||||
"expected_resolution": sc.expected_resolution,
|
||||
}
|
||||
for sid, sc in SCENARIOS.items()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.post("/test/seed/{scenario_id}")
|
||||
async def test_seed(scenario_id: str, request: Request) -> dict:
|
||||
"""
|
||||
Seed the database with pre-defined world-model state for a named scenario.
|
||||
After seeding, POST the trigger fact to /iknowthat to cause the collision.
|
||||
|
||||
Available scenarios: A (misclassification), B (ISA+ISA decompose), C (ISPART+ISPART)
|
||||
"""
|
||||
pool = request.app.state.pool
|
||||
return await seed_scenario(pool, scenario_id)
|
||||
|
||||
|
||||
@app.post("/test/reset/{scenario_id}")
|
||||
async def test_reset(scenario_id: str, request: Request) -> dict:
|
||||
"""
|
||||
Remove URD edges, SOAS saliency boosts, and resolution queue rows
|
||||
introduced by a scenario seed. Use before re-running a scenario.
|
||||
"""
|
||||
pool = request.app.state.pool
|
||||
return await reset_scenario(pool, scenario_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /admin — minimal HTML UI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ADMIN_HTML = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Festinger Admin</title>
|
||||
<style>
|
||||
body {{ font-family: monospace; max-width: 960px; margin: 40px auto; padding: 0 20px; color: #222; }}
|
||||
h1 {{ font-size: 1.4em; margin-bottom: 0.2em; }}
|
||||
h2 {{ font-size: 1.1em; margin-top: 2em; border-bottom: 1px solid #ddd; padding-bottom: 4px; }}
|
||||
.subtitle {{ color: #666; font-size: 0.85em; margin-bottom: 1.5em; }}
|
||||
.stats {{ display: flex; gap: 2em; flex-wrap: wrap; margin: 1em 0; }}
|
||||
.stat {{ background: #f8f8f8; border: 1px solid #e0e0e0; border-radius: 4px; padding: 12px 20px; min-width: 130px; }}
|
||||
.stat-label {{ font-size: 0.75em; color: #666; text-transform: uppercase; letter-spacing: 0.05em; }}
|
||||
.stat-value {{ font-size: 1.8em; font-weight: bold; margin-top: 2px; }}
|
||||
.actions {{ display: flex; gap: 1em; flex-wrap: wrap; margin: 1em 0; }}
|
||||
button {{ padding: 8px 18px; cursor: pointer; border: 1px solid #aaa; background: #fff; border-radius: 3px; font-family: monospace; }}
|
||||
button:hover {{ background: #f0f0f0; }}
|
||||
button.primary {{ background: #1a1a2e; color: #fff; border-color: #1a1a2e; }}
|
||||
button.primary:hover {{ background: #2a2a4e; }}
|
||||
button:disabled {{ opacity: 0.5; cursor: not-allowed; }}
|
||||
pre {{ background: #f4f4f4; border: 1px solid #e0e0e0; border-radius: 3px; padding: 1em; overflow: auto; font-size: 0.85em; max-height: 400px; }}
|
||||
.status-ok {{ color: #2a7a2a; }}
|
||||
.status-err {{ color: #b00; }}
|
||||
footer {{ margin-top: 3em; padding-top: 1em; border-top: 1px solid #ddd; font-size: 0.78em; color: #888; }}
|
||||
footer a {{ color: #888; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Festinger</h1>
|
||||
<p class="subtitle">Ollama-compatible inference middleware — loop detection & Recollections world model</p>
|
||||
|
||||
<h2>World model stats</h2>
|
||||
<div class="stats" id="stats">
|
||||
<div class="stat"><div class="stat-label">SOAS tokens</div><div class="stat-value" id="s-soas">…</div></div>
|
||||
<div class="stat"><div class="stat-label">URD edges</div><div class="stat-value" id="s-urd">…</div></div>
|
||||
<div class="stat"><div class="stat-label">Pending conflicts</div><div class="stat-value" id="s-conflicts">…</div></div>
|
||||
<div class="stat"><div class="stat-label">Last resolution</div><div class="stat-value" style="font-size:0.85em" id="s-lastrun">…</div></div>
|
||||
</div>
|
||||
|
||||
<h2>Actions</h2>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick="runResolution(this)">Run conflict resolution now</button>
|
||||
<button onclick="runWordnetImport(this)">Import WordNet lemmas</button>
|
||||
</div>
|
||||
<pre id="result" style="display:none"></pre>
|
||||
|
||||
<h2>Pending conflicts</h2>
|
||||
<pre id="conflicts-pre">Loading…</pre>
|
||||
|
||||
<footer>
|
||||
<strong>Vocabulary source:</strong>
|
||||
Princeton University “About WordNet.” <em>WordNet.</em> Princeton University. 2010.
|
||||
<a href="https://wordnet.princeton.edu/" target="_blank">https://wordnet.princeton.edu/</a>
|
||||
— used to pre-seed the SOAS concept vocabulary at saliency 0 (common English baseline).
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
async function loadStats() {{
|
||||
const r = await fetch('/health');
|
||||
const d = await r.json();
|
||||
document.getElementById('s-soas').textContent = d.soas_tokens.toLocaleString();
|
||||
document.getElementById('s-urd').textContent = d.urd_edges.toLocaleString();
|
||||
document.getElementById('s-conflicts').textContent = d.pending_conflicts;
|
||||
document.getElementById('s-lastrun').textContent = d.last_resolution_run
|
||||
? d.last_resolution_run.replace('T', ' ').slice(0, 19)
|
||||
: 'never';
|
||||
}}
|
||||
|
||||
async function loadConflicts() {{
|
||||
const r = await fetch('/conflicts');
|
||||
const d = await r.json();
|
||||
const pending = d.conflicts.filter(c => c.status === 'pending');
|
||||
const el = document.getElementById('conflicts-pre');
|
||||
el.textContent = pending.length
|
||||
? JSON.stringify(pending, null, 2)
|
||||
: '(none)';
|
||||
}}
|
||||
|
||||
function showResult(text, ok) {{
|
||||
const el = document.getElementById('result');
|
||||
el.style.display = 'block';
|
||||
el.className = ok ? 'status-ok' : 'status-err';
|
||||
el.textContent = text;
|
||||
}}
|
||||
|
||||
async function runResolution(btn) {{
|
||||
btn.disabled = true;
|
||||
showResult('Running resolution job…', true);
|
||||
try {{
|
||||
const r = await fetch('/resolve/run', {{method: 'POST'}});
|
||||
const d = await r.json();
|
||||
showResult(JSON.stringify(d, null, 2), r.ok);
|
||||
await loadStats();
|
||||
await loadConflicts();
|
||||
}} catch(e) {{
|
||||
showResult('Error: ' + e.message, false);
|
||||
}} finally {{
|
||||
btn.disabled = false;
|
||||
}}
|
||||
}}
|
||||
|
||||
async function runWordnetImport(btn) {{
|
||||
btn.disabled = true;
|
||||
showResult('Importing WordNet lemmas — this may take 10–20 seconds…', true);
|
||||
try {{
|
||||
const r = await fetch('/wordnet/import', {{method: 'POST'}});
|
||||
const d = await r.json();
|
||||
showResult(JSON.stringify(d, null, 2), r.ok && !d.error);
|
||||
await loadStats();
|
||||
}} catch(e) {{
|
||||
showResult('Error: ' + e.message, false);
|
||||
}} finally {{
|
||||
btn.disabled = false;
|
||||
}}
|
||||
}}
|
||||
|
||||
loadStats();
|
||||
loadConflicts();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
async def admin() -> str:
|
||||
return ADMIN_HTML.format()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Passthrough — everything else forwarded to upstream Ollama
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "HEAD"])
|
||||
async def passthrough(path: str, request: Request) -> Response:
|
||||
cfg = request.app.state.yaml_config
|
||||
# Route /v1/* to Anthropic; everything else (including /api/*) to Ollama
|
||||
if path.startswith("v1/"):
|
||||
upstream = cfg["upstream_anthropic"]
|
||||
relay_headers = ANTHROPIC_RELAY_HEADERS
|
||||
else:
|
||||
upstream = cfg["upstream_ollama"]
|
||||
relay_headers = None
|
||||
|
||||
body = await request.body()
|
||||
if relay_headers:
|
||||
headers = _relay_headers(request, relay_headers)
|
||||
else:
|
||||
headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
r = await client.request(
|
||||
request.method,
|
||||
f"{upstream}/{path}",
|
||||
content=body,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=r.content,
|
||||
status_code=r.status_code,
|
||||
media_type=r.headers.get("content-type"),
|
||||
)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Recollection engine — read path.
|
||||
|
||||
For each salient concept found in an intercepted prompt:
|
||||
- Query URD edges (from in-memory cache, filtered by confidence + recency)
|
||||
- Render hit or zero-hit block
|
||||
- Inject the <recollection> block into the prompt before forwarding to Ollama
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from . import cache
|
||||
from .cache import SoasRow, UrdEdge
|
||||
|
||||
log = logging.getLogger("festinger.recollection")
|
||||
|
||||
ZERO_HIT_TEMPLATE = (
|
||||
"? {concept}: no recollection. If not a typo, store it before proceeding:\n"
|
||||
" gutask iknowthat '{concept} -isa <parent> in context of <dimension>'\n"
|
||||
" gutask iknowthat '{concept} -ispart <system> in context of <dimension>'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query (reads from in-memory cache)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def query_edges(
|
||||
concept_id: int,
|
||||
confidence_floor: float,
|
||||
recency_days: int,
|
||||
) -> list[UrdEdge]:
|
||||
"""
|
||||
Return URD edges for *concept_id* that pass confidence + recency filters.
|
||||
All reads are pure in-memory — zero network.
|
||||
Note: we store last_confirmed as a datetime in the edge when warming the
|
||||
cache. For simplicity the cache stores it as a string from Postgres;
|
||||
the recency filter is intentionally lenient when last_confirmed is absent.
|
||||
"""
|
||||
edges = cache.urd_by_concept.get(concept_id, [])
|
||||
if not edges:
|
||||
return []
|
||||
|
||||
cutoff = datetime.now(tz=timezone.utc) - timedelta(days=recency_days)
|
||||
result = []
|
||||
for e in edges:
|
||||
if e.confidence < confidence_floor:
|
||||
continue
|
||||
result.append(e)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Renderers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def render_hit(concept_token: str, edges: list[UrdEdge], concept_id: int) -> str:
|
||||
"""
|
||||
Render one concept's recollection line:
|
||||
gnommoweb: [type] repo [membership] glitch_university
|
||||
Pending-conflict dimensions get a '?' suffix on the dim token.
|
||||
"""
|
||||
has_pending = concept_id in cache.pending_conflicts
|
||||
parts = []
|
||||
for e in edges:
|
||||
dim_label = e.dim_token
|
||||
if has_pending:
|
||||
dim_label += "?"
|
||||
parts.append(f"[{dim_label}] {e.parent_token}")
|
||||
return f"{concept_token}: {' '.join(parts)}"
|
||||
|
||||
|
||||
def render_zero_hit(concept_token: str) -> str:
|
||||
return ZERO_HIT_TEMPLATE.format(concept=concept_token)
|
||||
|
||||
|
||||
def build_recollection_block(
|
||||
salient_concept_ids: list[int],
|
||||
confidence_floor: float,
|
||||
recency_days: int,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Build the full <recollection> block for a list of above-threshold concept IDs.
|
||||
Returns None if there is nothing to say.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
for cid in salient_concept_ids:
|
||||
token = cache.soas_by_id.get(cid, str(cid))
|
||||
edges = query_edges(cid, confidence_floor, recency_days)
|
||||
|
||||
if edges:
|
||||
lines.append(render_hit(token, edges, cid))
|
||||
else:
|
||||
lines.append(render_zero_hit(token))
|
||||
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
body = "\n".join(lines)
|
||||
return f"<recollection>\n{body}\n</recollection>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt injection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def inject_recollection(messages: list[dict], block: str) -> list[dict]:
|
||||
"""
|
||||
Prepend the recollection block to the first system message.
|
||||
If no system message exists, insert one at position 0.
|
||||
Returns a new messages list (does not mutate the input).
|
||||
"""
|
||||
messages = list(messages)
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.get("role") == "system":
|
||||
messages[i] = dict(msg)
|
||||
messages[i]["content"] = block + "\n\n" + (msg.get("content") or "")
|
||||
return messages
|
||||
|
||||
# No system message — insert one
|
||||
messages.insert(0, {"role": "system", "content": block})
|
||||
return messages
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Nightly resolution job — drain the resolution queue via cloud LLM.
|
||||
|
||||
For each pending conflict:
|
||||
- ISA+ISA → decompose (two new dimensions) or dismiss
|
||||
- ISPART+ISPART → update (swap to incoming) or dismiss
|
||||
- misclassification → reclassify (insert in correct dimension)
|
||||
|
||||
Triggered by APScheduler on the cron schedule in config table,
|
||||
or manually via POST /resolve/run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import asyncpg
|
||||
|
||||
from . import cache
|
||||
from .db import get_or_create_soas, get_config, reload_urd_cache
|
||||
from .llm_client import (
|
||||
ModelConfig, get_model_config, call_llm,
|
||||
RESOLVE_ISA_ISA_PROMPT, RESOLVE_ISPART_ISPART_PROMPT,
|
||||
RESOLVE_MISCLASS_PROMPT, parse_resolution_decision,
|
||||
)
|
||||
from .urd_writer import InsertRequest, insert_urd_edge
|
||||
|
||||
log = logging.getLogger("festinger.resolution")
|
||||
|
||||
_last_run: datetime | None = None
|
||||
|
||||
|
||||
async def run_resolution_job(pool: asyncpg.Pool) -> dict:
|
||||
"""
|
||||
Process all pending resolution queue items.
|
||||
Returns a summary dict.
|
||||
"""
|
||||
global _last_run
|
||||
log.info("resolution job starting")
|
||||
|
||||
resolve_model_id = await get_config(pool, "resolve_model_id")
|
||||
if not resolve_model_id:
|
||||
log.warning("no resolve_model_id configured — resolution job aborted")
|
||||
return {"status": "aborted", "reason": "no resolve_model_id"}
|
||||
|
||||
model = await get_model_config(pool, resolve_model_id)
|
||||
if not model:
|
||||
log.warning("resolve_model_id=%s not found in models table", resolve_model_id)
|
||||
return {"status": "aborted", "reason": "model not found"}
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
items = await conn.fetch(
|
||||
"""
|
||||
SELECT id, concept_id, existing_parent_id, incoming_parent_id,
|
||||
dim_id, collision_type
|
||||
FROM resolution_queue
|
||||
WHERE status = 'pending'
|
||||
ORDER BY priority DESC, created_at ASC
|
||||
"""
|
||||
)
|
||||
|
||||
counts = {"decompose": 0, "update": 0, "dismiss": 0, "reclassify": 0, "error": 0}
|
||||
|
||||
for item in items:
|
||||
try:
|
||||
outcome = await _resolve_item(pool, model, item)
|
||||
counts[outcome] = counts.get(outcome, 0) + 1
|
||||
except Exception as e:
|
||||
log.exception("resolution error for queue item %d: %s", item["id"], e)
|
||||
counts["error"] += 1
|
||||
|
||||
# Reload URD cache after all resolutions
|
||||
await reload_urd_cache(pool)
|
||||
_last_run = datetime.now(tz=timezone.utc)
|
||||
log.info("resolution job complete: %s", counts)
|
||||
return {"status": "ok", "counts": counts, "processed": len(items)}
|
||||
|
||||
|
||||
async def _resolve_item(pool: asyncpg.Pool, model: ModelConfig, item) -> str:
|
||||
concept_token = cache.soas_by_id.get(item["concept_id"], str(item["concept_id"]))
|
||||
existing_parent = cache.soas_by_id.get(item["existing_parent_id"], "?")
|
||||
incoming_parent = cache.soas_by_id.get(item["incoming_parent_id"], "?")
|
||||
dim_token = cache.soas_by_id.get(item["dim_id"], "?")
|
||||
seed_dims = ["type", "membership", "runs-on", "tech", "owned-by", "geography"]
|
||||
known_dims_str = ", ".join(seed_dims)
|
||||
|
||||
collision_type = item["collision_type"]
|
||||
|
||||
if collision_type == "isa_isa":
|
||||
prompt = RESOLVE_ISA_ISA_PROMPT.format(
|
||||
concept=concept_token,
|
||||
existing_parent=existing_parent,
|
||||
incoming_parent=incoming_parent,
|
||||
dimension=dim_token,
|
||||
known_dimensions=known_dims_str,
|
||||
)
|
||||
elif collision_type == "ispart_ispart":
|
||||
prompt = RESOLVE_ISPART_ISPART_PROMPT.format(
|
||||
concept=concept_token,
|
||||
existing_parent=existing_parent,
|
||||
incoming_parent=incoming_parent,
|
||||
dimension=dim_token,
|
||||
)
|
||||
else: # misclassification
|
||||
prompt = RESOLVE_MISCLASS_PROMPT.format(
|
||||
concept=concept_token,
|
||||
existing_parent=existing_parent,
|
||||
incoming_parent=incoming_parent,
|
||||
dimension=dim_token,
|
||||
known_dimensions=known_dims_str,
|
||||
)
|
||||
|
||||
response = await call_llm(model, prompt)
|
||||
decision = parse_resolution_decision(response)
|
||||
|
||||
if not decision:
|
||||
await _mark_resolved(pool, item["id"], "error", {"raw_response": response[:500]})
|
||||
return "error"
|
||||
|
||||
outcome = decision.decision
|
||||
|
||||
if outcome == "decompose" and collision_type == "isa_isa":
|
||||
await _apply_decompose(pool, item, decision, concept_token, existing_parent, incoming_parent)
|
||||
elif outcome == "update" and collision_type == "ispart_ispart":
|
||||
await _apply_update(pool, item)
|
||||
elif outcome == "reclassify":
|
||||
await _apply_reclassify(pool, item, decision)
|
||||
else:
|
||||
outcome = "dismiss"
|
||||
|
||||
await _mark_resolved(
|
||||
pool, item["id"], "resolved",
|
||||
{"decision": outcome, "reasoning": decision.reasoning},
|
||||
)
|
||||
return outcome
|
||||
|
||||
|
||||
async def _apply_decompose(pool, item, decision, concept_token, existing_parent, incoming_parent):
|
||||
"""Create two new dimensions, migrate existing fact, insert incoming fact."""
|
||||
existing_dim_token = decision.existing_dimension or "type-a"
|
||||
new_dim_token = decision.new_dimension or "type-b"
|
||||
|
||||
existing_dim_row = await get_or_create_soas(pool, existing_dim_token)
|
||||
new_dim_row = await get_or_create_soas(pool, new_dim_token)
|
||||
|
||||
# Create root nodes for new dimensions
|
||||
async with pool.acquire() as conn:
|
||||
for dim_row in [existing_dim_row, new_dim_row]:
|
||||
await conn.execute(
|
||||
"INSERT INTO urd (id, parent_id, dim_id, is_isa, confidence, source) "
|
||||
"VALUES ($1, $1, $1, true, 1.0, 'festinger') ON CONFLICT DO NOTHING",
|
||||
dim_row.id,
|
||||
)
|
||||
|
||||
# Migrate existing edge to new existing_dimension
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"DELETE FROM urd WHERE id=$1 AND dim_id=$2",
|
||||
item["concept_id"], item["dim_id"],
|
||||
)
|
||||
|
||||
existing_parent_row = await get_or_create_soas(pool, cache.soas_by_id.get(item["existing_parent_id"], existing_parent))
|
||||
incoming_parent_row = await get_or_create_soas(pool, cache.soas_by_id.get(item["incoming_parent_id"], incoming_parent))
|
||||
|
||||
for parent_row, dim_row in [
|
||||
(existing_parent_row, existing_dim_row),
|
||||
(incoming_parent_row, new_dim_row),
|
||||
]:
|
||||
req = InsertRequest(
|
||||
concept_id=item["concept_id"],
|
||||
parent_id=parent_row.id,
|
||||
dim_id=dim_row.id,
|
||||
is_isa=True,
|
||||
confidence=0.9,
|
||||
source="festinger",
|
||||
)
|
||||
await insert_urd_edge(pool, req)
|
||||
|
||||
log.info("decompose applied: %s → [%s, %s]", item["concept_id"], existing_dim_token, new_dim_token)
|
||||
|
||||
|
||||
async def _apply_update(pool, item):
|
||||
"""Replace old ISPART edge with incoming fact."""
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"DELETE FROM urd WHERE id=$1 AND dim_id=$2",
|
||||
item["concept_id"], item["dim_id"],
|
||||
)
|
||||
req = InsertRequest(
|
||||
concept_id=item["concept_id"],
|
||||
parent_id=item["incoming_parent_id"],
|
||||
dim_id=item["dim_id"],
|
||||
is_isa=False,
|
||||
confidence=0.85,
|
||||
source="festinger",
|
||||
)
|
||||
await insert_urd_edge(pool, req)
|
||||
log.info("update applied: concept=%d dim=%d → parent=%d",
|
||||
item["concept_id"], item["dim_id"], item["incoming_parent_id"])
|
||||
|
||||
|
||||
async def _apply_reclassify(pool, item, decision):
|
||||
"""Insert incoming fact in the corrected dimension."""
|
||||
correct_dim_token = decision.correct_dimension or "membership"
|
||||
dim_row = await get_or_create_soas(pool, correct_dim_token)
|
||||
req = InsertRequest(
|
||||
concept_id=item["concept_id"],
|
||||
parent_id=item["incoming_parent_id"],
|
||||
dim_id=dim_row.id,
|
||||
is_isa=False,
|
||||
confidence=0.8,
|
||||
source="festinger",
|
||||
)
|
||||
await insert_urd_edge(pool, req)
|
||||
log.info("reclassify applied: concept=%d → dim=%s", item["concept_id"], correct_dim_token)
|
||||
|
||||
|
||||
async def _mark_resolved(pool, queue_id: int, status: str, resolution: dict) -> None:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE resolution_queue SET status=$1, resolution=$2, resolved_at=now() WHERE id=$3",
|
||||
status, json.dumps(resolution), queue_id,
|
||||
)
|
||||
|
||||
|
||||
def last_run_timestamp() -> str | None:
|
||||
if _last_run is None:
|
||||
return None
|
||||
return _last_run.isoformat()
|
||||
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Test scenario seeder for Festinger.
|
||||
|
||||
Seeds the database and in-memory cache with pre-defined world-model states,
|
||||
then exposes a trigger endpoint so gutasktool (or a test script) can fire the
|
||||
conflicting fact and observe the collision + resolution pipeline.
|
||||
|
||||
Scenarios
|
||||
---------
|
||||
|
||||
A misclassification
|
||||
Seed: michigan ISPART usa in dim:usa (parent_id = dim_id — coarse/degenerate edge)
|
||||
Trigger: gutask iknowthat 'michigan -isa state in context of usa'
|
||||
Expect: misclassification collision → resolution moves ISA fact to dim:type
|
||||
|
||||
B ISA + ISA decomposition
|
||||
Seed: gnommoweb ISA container in dim:type
|
||||
Trigger: gutask iknowthat 'gnommoweb -isa repo in context of type'
|
||||
Expect: isa_isa collision → nightly job decomposes into artifact-type + deployment-type
|
||||
|
||||
C ISPART + ISPART contradiction
|
||||
Seed: dobby ISPART docker_host_1 in dim:runs-on
|
||||
Trigger: gutask iknowthat 'dobby -ispart docker_host_2 in context of runs-on'
|
||||
Expect: ispart_ispart collision → nightly job arbitrates (update or dismiss)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from . import cache
|
||||
from .cache import SoasRow, UrdEdge
|
||||
from .db import get_or_create_soas
|
||||
|
||||
log = logging.getLogger("festinger.test")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class SeedEdge:
|
||||
concept: str
|
||||
parent: str
|
||||
dimension: str
|
||||
is_isa: bool
|
||||
confidence: float = 0.9
|
||||
source: str = "test"
|
||||
note: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scenario:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
seed_edges: list[SeedEdge]
|
||||
trigger_fact: str # the fact to POST to /iknowthat to cause the collision
|
||||
expected_collision: str # 'isa_isa' | 'ispart_ispart' | 'misclassification'
|
||||
expected_resolution: str # 'decompose' | 'update' | 'dismiss' | 'reclassify'
|
||||
notes: str = ""
|
||||
|
||||
|
||||
SCENARIOS: dict[str, Scenario] = {
|
||||
|
||||
"A": Scenario(
|
||||
id="A",
|
||||
name="misclassification — michigan/state/usa",
|
||||
description=(
|
||||
"The world model holds a coarse, degenerate fact: michigan ISPART usa "
|
||||
"in the 'usa' dimension (parent_id = dim_id). This is an early, "
|
||||
"undifferentiated encoding — the location context IS the dimension. "
|
||||
"A new fact 'michigan is a state in context of usa' is ISA in the same "
|
||||
"dimension, triggering a misclassification collision. Resolution should "
|
||||
"suggest moving the ISA fact to the 'type' dimension."
|
||||
),
|
||||
seed_edges=[
|
||||
SeedEdge(
|
||||
concept="michigan",
|
||||
parent="usa",
|
||||
dimension="usa", # parent_id = dim_id — the degenerate pattern
|
||||
is_isa=False, # ISPART
|
||||
confidence=0.85,
|
||||
note="coarse pre-knowledge: michigan is part of usa, dim=usa (undifferentiated)",
|
||||
),
|
||||
],
|
||||
trigger_fact="michigan -isa state in context of usa",
|
||||
expected_collision="misclassification",
|
||||
expected_resolution="reclassify",
|
||||
notes=(
|
||||
"The degenerate parent_id=dim_id pattern arises naturally when the world model "
|
||||
"first hears 'michigan is in usa' before any geography dimension exists. "
|
||||
"Resolution should split: ISPART goes to 'geography', ISA goes to 'type'."
|
||||
),
|
||||
),
|
||||
|
||||
"B": Scenario(
|
||||
id="B",
|
||||
name="ISA+ISA decomposition — gnommoweb/container/repo",
|
||||
description=(
|
||||
"The world model holds gnommoweb ISA container in dim:type. "
|
||||
"Both 'container' (deployment artifact) and 'repo' (software artifact) are "
|
||||
"simultaneously true, but the 'type' dimension is too coarse to hold both. "
|
||||
"Resolution should decompose into 'deployment-type' and 'artifact-type'."
|
||||
),
|
||||
seed_edges=[
|
||||
SeedEdge(
|
||||
concept="gnommoweb",
|
||||
parent="container",
|
||||
dimension="type",
|
||||
is_isa=True,
|
||||
confidence=0.9,
|
||||
note="gnommoweb runs as a Docker container",
|
||||
),
|
||||
],
|
||||
trigger_fact="gnommoweb -isa repo in context of type",
|
||||
expected_collision="isa_isa",
|
||||
expected_resolution="decompose",
|
||||
notes="Classic dimension-too-coarse case from PROJECT.md test case B.",
|
||||
),
|
||||
|
||||
"C": Scenario(
|
||||
id="C",
|
||||
name="ISPART+ISPART contradiction — dobby/host migration",
|
||||
description=(
|
||||
"The world model holds dobby ISPART docker_host_1 in dim:runs-on. "
|
||||
"A new fact places dobby on docker_host_2, contradicting the existing "
|
||||
"runs-on edge. One host is correct; the nightly job must arbitrate."
|
||||
),
|
||||
seed_edges=[
|
||||
SeedEdge(
|
||||
concept="dobby",
|
||||
parent="docker_host_1",
|
||||
dimension="runs-on",
|
||||
is_isa=False,
|
||||
confidence=0.9,
|
||||
note="dobby agent originally deployed on docker_host_1",
|
||||
),
|
||||
],
|
||||
trigger_fact="dobby -ispart docker_host_2 in context of runs-on",
|
||||
expected_collision="ispart_ispart",
|
||||
expected_resolution="update",
|
||||
notes="Simulates a container migration where the new host fact should win.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seeder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def seed_scenario(pool: asyncpg.Pool, scenario_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Insert SOAS + URD entries for a named scenario.
|
||||
Returns a summary of what was created, including the token→id mapping
|
||||
and the trigger fact to POST to /iknowthat.
|
||||
"""
|
||||
sc = SCENARIOS.get(scenario_id.upper())
|
||||
if sc is None:
|
||||
return {"error": f"unknown scenario: {scenario_id!r}. Valid: {list(SCENARIOS)}"}
|
||||
|
||||
created_soas: list[dict] = []
|
||||
created_edges: list[dict] = []
|
||||
collisions: list[dict] = []
|
||||
|
||||
for edge in sc.seed_edges:
|
||||
concept_row = await get_or_create_soas(pool, edge.concept)
|
||||
parent_row = await get_or_create_soas(pool, edge.parent)
|
||||
dim_row = await get_or_create_soas(pool, edge.dimension)
|
||||
|
||||
for row in [concept_row, parent_row, dim_row]:
|
||||
created_soas.append({"token": row.token, "id": row.id})
|
||||
|
||||
# Manually set encounter_count + saliency above read threshold so
|
||||
# the concept appears in recollections during the test prompt.
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE soas SET encounter_count=50, saliency=1.5 WHERE id=$1",
|
||||
concept_row.id,
|
||||
)
|
||||
if concept_row.token in cache.soas_by_token:
|
||||
cache.soas_by_token[concept_row.token].encounter_count = 50
|
||||
cache.soas_by_token[concept_row.token].saliency = 1.5
|
||||
|
||||
# Insert URD edge — direct Postgres write, bypassing collision detection,
|
||||
# because this is deliberate seed state (not a user-facing write path).
|
||||
key = (concept_row.id, dim_row.id)
|
||||
existing = cache.urd_by_concept_dim.get(key)
|
||||
|
||||
if existing is not None:
|
||||
collisions.append({
|
||||
"concept": edge.concept,
|
||||
"dimension": edge.dimension,
|
||||
"message": "edge already present in cache — skipped",
|
||||
})
|
||||
continue
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO urd (id, parent_id, dim_id, is_isa, confidence, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
concept_row.id, parent_row.id, dim_row.id,
|
||||
edge.is_isa, edge.confidence, edge.source,
|
||||
)
|
||||
except Exception as e:
|
||||
collisions.append({"concept": edge.concept, "error": str(e)})
|
||||
continue
|
||||
|
||||
# Update in-memory cache
|
||||
urd_edge = UrdEdge(
|
||||
concept_id=concept_row.id,
|
||||
parent_id=parent_row.id,
|
||||
dim_id=dim_row.id,
|
||||
is_isa=edge.is_isa,
|
||||
confidence=edge.confidence,
|
||||
source=edge.source,
|
||||
parent_token=parent_row.token,
|
||||
dim_token=dim_row.token,
|
||||
)
|
||||
cache.urd_by_concept.setdefault(concept_row.id, []).append(urd_edge)
|
||||
cache.urd_by_concept_dim[key] = urd_edge
|
||||
|
||||
created_edges.append({
|
||||
"concept": edge.concept,
|
||||
"concept_id": concept_row.id,
|
||||
"parent": edge.parent,
|
||||
"parent_id": parent_row.id,
|
||||
"dimension": edge.dimension,
|
||||
"dim_id": dim_row.id,
|
||||
"is_isa": edge.is_isa,
|
||||
"parent_equals_dim": parent_row.id == dim_row.id,
|
||||
"note": edge.note,
|
||||
})
|
||||
|
||||
return {
|
||||
"scenario": sc.id,
|
||||
"name": sc.name,
|
||||
"description": sc.description,
|
||||
"soas_entries": created_soas,
|
||||
"urd_edges": created_edges,
|
||||
"skipped_collisions": collisions,
|
||||
"next_step": {
|
||||
"action": "POST /iknowthat",
|
||||
"body": {"fact": sc.trigger_fact},
|
||||
"gutask_command": f"gutask iknowthat '{sc.trigger_fact}'",
|
||||
"expected_collision": sc.expected_collision,
|
||||
"expected_resolution": sc.expected_resolution,
|
||||
},
|
||||
"notes": sc.notes,
|
||||
}
|
||||
|
||||
|
||||
async def reset_scenario(pool: asyncpg.Pool, scenario_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Remove the URD edges and SOAS tokens introduced by a scenario seed.
|
||||
Clears the corresponding cache entries and resolution queue rows.
|
||||
Use before re-running a scenario to get a clean slate.
|
||||
"""
|
||||
sc = SCENARIOS.get(scenario_id.upper())
|
||||
if sc is None:
|
||||
return {"error": f"unknown scenario: {scenario_id!r}"}
|
||||
|
||||
removed_urd = 0
|
||||
removed_rq = 0
|
||||
|
||||
concept_tokens = {e.concept for e in sc.seed_edges}
|
||||
concept_ids = [
|
||||
cache.soas_by_token[t].id
|
||||
for t in concept_tokens
|
||||
if t in cache.soas_by_token
|
||||
]
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
for cid in concept_ids:
|
||||
# Remove resolution queue entries
|
||||
r = await conn.execute(
|
||||
"DELETE FROM resolution_queue WHERE concept_id=$1", cid
|
||||
)
|
||||
removed_rq += int(r.split()[-1])
|
||||
|
||||
# Remove URD edges where this concept is the subject
|
||||
r = await conn.execute("DELETE FROM urd WHERE id=$1", cid)
|
||||
removed_urd += int(r.split()[-1])
|
||||
|
||||
# Clear from in-memory cache
|
||||
cache.urd_by_concept.pop(cid, None)
|
||||
for key in [k for k in cache.urd_by_concept_dim if k[0] == cid]:
|
||||
del cache.urd_by_concept_dim[key]
|
||||
cache.pending_conflicts.discard(cid)
|
||||
|
||||
return {
|
||||
"scenario": scenario_id.upper(),
|
||||
"removed_urd_edges": removed_urd,
|
||||
"removed_resolution_queue_rows": removed_rq,
|
||||
"status": "reset complete",
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Tokeniser — compound token rule + punctuation stripping.
|
||||
|
||||
Rules:
|
||||
1. Split on whitespace.
|
||||
2. Consecutive tokens each starting with a capital letter are merged into a
|
||||
single underscore-joined lowercase token (compound token rule).
|
||||
A lowercase or non-alpha token breaks the run.
|
||||
3. Strip leading/trailing punctuation from each token.
|
||||
4. Lowercase all tokens.
|
||||
5. Discard tokens shorter than 5 characters (unless they surfaced as part of
|
||||
a matched relationship cue — cue scanner handles that separately).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
# Characters that are stripped from token edges
|
||||
_PUNCT_RE = re.compile(r"^[^\w]+|[^\w]+$")
|
||||
|
||||
|
||||
def _strip_punct(s: str) -> str:
|
||||
return _PUNCT_RE.sub("", s)
|
||||
|
||||
|
||||
def tokenize(text: str) -> list[str]:
|
||||
"""
|
||||
Return a deduplicated, ordered list of canonical tokens extracted from
|
||||
*text*, applying the compound token rule.
|
||||
"""
|
||||
raw_words = text.split()
|
||||
tokens: list[str] = []
|
||||
run: list[str] = []
|
||||
|
||||
def flush_run() -> None:
|
||||
if not run:
|
||||
return
|
||||
if len(run) > 1:
|
||||
tokens.append("_".join(w.lower() for w in run))
|
||||
else:
|
||||
tokens.append(run[0].lower())
|
||||
run.clear()
|
||||
|
||||
for word in raw_words:
|
||||
stripped = _strip_punct(word)
|
||||
# Trailing punctuation (sentence boundary) ends a compound run.
|
||||
# Commas/colons/periods after a word mean the next word starts fresh.
|
||||
has_trailing_punct = bool(word) and not word[-1].isalnum() and word[-1] != "_"
|
||||
|
||||
if not stripped:
|
||||
flush_run()
|
||||
continue
|
||||
|
||||
# A word starts a capital run if its first alphabetic character is uppercase
|
||||
first_alpha = next((c for c in stripped if c.isalpha()), None)
|
||||
if first_alpha and first_alpha.isupper():
|
||||
run.append(stripped)
|
||||
if has_trailing_punct:
|
||||
flush_run() # e.g. "FastAPI." ends the run immediately
|
||||
else:
|
||||
flush_run()
|
||||
t = stripped.lower()
|
||||
if t:
|
||||
tokens.append(t)
|
||||
|
||||
flush_run()
|
||||
|
||||
# Filter: keep tokens >= 5 chars, deduplicate preserving order
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for t in tokens:
|
||||
if len(t) >= 5 and t not in seen:
|
||||
seen.add(t)
|
||||
result.append(t)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def tokenize_all(text: str) -> list[str]:
|
||||
"""
|
||||
Like tokenize() but returns ALL tokens (including short ones).
|
||||
Used by the cue scanner which needs short words for pattern matching.
|
||||
"""
|
||||
raw_words = text.split()
|
||||
tokens: list[str] = []
|
||||
run: list[str] = []
|
||||
|
||||
def flush_run() -> None:
|
||||
if not run:
|
||||
return
|
||||
if len(run) > 1:
|
||||
tokens.append("_".join(w.lower() for w in run))
|
||||
else:
|
||||
tokens.append(run[0].lower())
|
||||
run.clear()
|
||||
|
||||
for word in raw_words:
|
||||
stripped = _strip_punct(word)
|
||||
has_trailing_punct = bool(word) and not word[-1].isalnum() and word[-1] != "_"
|
||||
|
||||
if not stripped:
|
||||
flush_run()
|
||||
continue
|
||||
first_alpha = next((c for c in stripped if c.isalpha()), None)
|
||||
if first_alpha and first_alpha.isupper():
|
||||
run.append(stripped)
|
||||
if has_trailing_punct:
|
||||
flush_run()
|
||||
else:
|
||||
flush_run()
|
||||
t = stripped.lower()
|
||||
if t:
|
||||
tokens.append(t)
|
||||
|
||||
flush_run()
|
||||
return tokens
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
URD insert pipeline — collision detection and write-through.
|
||||
|
||||
Insert flow:
|
||||
key = (concept_id, dim_id)
|
||||
if key in urd_by_concept_dim → collision detected in-memory
|
||||
→ classify (is_isa flags), route to resolution queue
|
||||
else
|
||||
→ INSERT into Postgres URD
|
||||
→ on success: update urd_by_concept + urd_by_concept_dim
|
||||
→ on UniqueViolation (race): reload row, route to resolution queue
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import asyncpg
|
||||
|
||||
from . import cache
|
||||
from .cache import UrdEdge
|
||||
|
||||
log = logging.getLogger("festinger.urd_writer")
|
||||
|
||||
|
||||
@dataclass
|
||||
class InsertRequest:
|
||||
concept_id: int
|
||||
parent_id: int
|
||||
dim_id: int
|
||||
is_isa: bool
|
||||
confidence: float
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollisionInfo:
|
||||
concept_id: int
|
||||
existing_parent_id: int
|
||||
incoming_parent_id: int
|
||||
dim_id: int
|
||||
existing_is_isa: bool
|
||||
incoming_is_isa: bool
|
||||
|
||||
@property
|
||||
def collision_type(self) -> str:
|
||||
if self.existing_is_isa and self.incoming_is_isa:
|
||||
return "isa_isa"
|
||||
if not self.existing_is_isa and not self.incoming_is_isa:
|
||||
return "ispart_ispart"
|
||||
return "misclassification"
|
||||
|
||||
|
||||
async def insert_urd_edge(
|
||||
pool: asyncpg.Pool,
|
||||
req: InsertRequest,
|
||||
priority: bool = False,
|
||||
) -> Optional[CollisionInfo]:
|
||||
"""
|
||||
Attempt to insert a URD edge.
|
||||
Returns CollisionInfo if there was a collision, None on success.
|
||||
"""
|
||||
key = (req.concept_id, req.dim_id)
|
||||
|
||||
# Fast-path collision detection — in-memory
|
||||
existing = cache.urd_by_concept_dim.get(key)
|
||||
if existing is not None:
|
||||
collision = CollisionInfo(
|
||||
concept_id=req.concept_id,
|
||||
existing_parent_id=existing.parent_id,
|
||||
incoming_parent_id=req.parent_id,
|
||||
dim_id=req.dim_id,
|
||||
existing_is_isa=existing.is_isa,
|
||||
incoming_is_isa=req.is_isa,
|
||||
)
|
||||
await _queue_collision(pool, collision, priority)
|
||||
return collision
|
||||
|
||||
# Attempt Postgres insert
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO urd (id, parent_id, dim_id, is_isa, confidence, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
""",
|
||||
req.concept_id, req.parent_id, req.dim_id,
|
||||
req.is_isa, req.confidence, req.source,
|
||||
)
|
||||
except asyncpg.UniqueViolationError:
|
||||
# Race condition — another process inserted concurrently
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT parent_id, is_isa FROM urd WHERE id=$1 AND dim_id=$2",
|
||||
req.concept_id, req.dim_id,
|
||||
)
|
||||
if row:
|
||||
collision = CollisionInfo(
|
||||
concept_id=req.concept_id,
|
||||
existing_parent_id=row["parent_id"],
|
||||
incoming_parent_id=req.parent_id,
|
||||
dim_id=req.dim_id,
|
||||
existing_is_isa=row["is_isa"],
|
||||
incoming_is_isa=req.is_isa,
|
||||
)
|
||||
await _queue_collision(pool, collision, priority)
|
||||
return collision
|
||||
return None
|
||||
|
||||
# Success — update in-memory cache
|
||||
parent_token = cache.soas_by_id.get(req.parent_id, str(req.parent_id))
|
||||
dim_token = cache.soas_by_id.get(req.dim_id, str(req.dim_id))
|
||||
edge = UrdEdge(
|
||||
concept_id=req.concept_id,
|
||||
parent_id=req.parent_id,
|
||||
dim_id=req.dim_id,
|
||||
is_isa=req.is_isa,
|
||||
confidence=req.confidence,
|
||||
source=req.source,
|
||||
parent_token=parent_token,
|
||||
dim_token=dim_token,
|
||||
)
|
||||
cache.urd_by_concept.setdefault(req.concept_id, []).append(edge)
|
||||
cache.urd_by_concept_dim[key] = edge
|
||||
log.info(
|
||||
"urd insert concept=%d parent=%d dim=%d is_isa=%s source=%s",
|
||||
req.concept_id, req.parent_id, req.dim_id, req.is_isa, req.source,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _queue_collision(
|
||||
pool: asyncpg.Pool,
|
||||
col: CollisionInfo,
|
||||
priority: bool,
|
||||
) -> None:
|
||||
cache.pending_conflicts.add(col.concept_id)
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO resolution_queue
|
||||
(concept_id, existing_parent_id, incoming_parent_id, dim_id,
|
||||
collision_type, priority)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
""",
|
||||
col.concept_id,
|
||||
col.existing_parent_id,
|
||||
col.incoming_parent_id,
|
||||
col.dim_id,
|
||||
col.collision_type,
|
||||
priority,
|
||||
)
|
||||
log.warning(
|
||||
"collision queued concept=%d type=%s priority=%s",
|
||||
col.concept_id, col.collision_type, priority,
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
WordNet importer — loads Princeton WordNet 3.x index files into SOAS.
|
||||
|
||||
Reads index.noun, index.verb, index.adj, index.adv from the wordnet/ directory.
|
||||
Each non-header line's first field is the lemma (already lowercase, underscores
|
||||
for compound words — matches our compound token convention exactly).
|
||||
|
||||
All tokens are inserted with saliency=0, novelty=0 (common English baseline).
|
||||
Insert is idempotent: ON CONFLICT DO NOTHING.
|
||||
|
||||
Citation:
|
||||
Princeton University "About WordNet." WordNet. Princeton University. 2010.
|
||||
https://wordnet.princeton.edu/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
import asyncpg
|
||||
|
||||
from . import cache
|
||||
from .cache import SoasRow
|
||||
|
||||
log = logging.getLogger("festinger.wordnet")
|
||||
|
||||
WORDNET_DIR = Path(__file__).parent.parent / "wordnet"
|
||||
INDEX_FILES = ["index.noun", "index.verb", "index.adj", "index.adv"]
|
||||
BATCH_SIZE = 2000
|
||||
|
||||
CITATION = (
|
||||
'Princeton University "About WordNet." WordNet. '
|
||||
"Princeton University. 2010. https://wordnet.princeton.edu/"
|
||||
)
|
||||
|
||||
|
||||
def _parse_index_file(path: Path) -> list[str]:
|
||||
"""
|
||||
Extract lemma tokens from a WordNet index file.
|
||||
Header lines start with a space or are blank — skip them.
|
||||
Data line format: lemma pos synset_cnt p_cnt ...
|
||||
Lemmas are already lowercase; underscores join compound words.
|
||||
"""
|
||||
tokens: list[str] = []
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
if not line or line[0] in (" ", "\t", "\n"):
|
||||
continue
|
||||
lemma = line.split()[0]
|
||||
# Skip purely numeric tokens and single chars
|
||||
if lemma and not lemma.isdigit() and len(lemma) > 1:
|
||||
tokens.append(lemma)
|
||||
except FileNotFoundError:
|
||||
log.warning("wordnet file not found: %s", path)
|
||||
return tokens
|
||||
|
||||
|
||||
def collect_all_lemmas() -> list[str]:
|
||||
"""Parse all four index files and return a deduplicated list of lemmas."""
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for fname in INDEX_FILES:
|
||||
for token in _parse_index_file(WORDNET_DIR / fname):
|
||||
if token not in seen:
|
||||
seen.add(token)
|
||||
result.append(token)
|
||||
return result
|
||||
|
||||
|
||||
async def import_wordnet(pool: asyncpg.Pool) -> dict:
|
||||
"""
|
||||
Bulk-load all WordNet lemmas into SOAS (saliency=0, novelty=0).
|
||||
Updates the in-memory cache with any newly inserted tokens.
|
||||
Returns a summary dict.
|
||||
"""
|
||||
if not WORDNET_DIR.exists():
|
||||
return {"error": f"wordnet directory not found at {WORDNET_DIR}"}
|
||||
|
||||
lemmas = collect_all_lemmas()
|
||||
total = len(lemmas)
|
||||
log.info("wordnet: %d lemmas collected, beginning import …", total)
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Process in batches to avoid huge transactions
|
||||
for i in range(0, total, BATCH_SIZE):
|
||||
batch = lemmas[i : i + BATCH_SIZE]
|
||||
|
||||
# INSERT … ON CONFLICT DO NOTHING, then RETURNING to know what was new
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
INSERT INTO soas (token, saliency, novelty)
|
||||
SELECT unnest($1::text[]), 0.0, 0.0
|
||||
ON CONFLICT (token) DO NOTHING
|
||||
RETURNING id, token
|
||||
""",
|
||||
batch,
|
||||
)
|
||||
|
||||
for r in rows:
|
||||
soas_row = SoasRow(id=r["id"], token=r["token"])
|
||||
cache.soas_by_token[r["token"]] = soas_row
|
||||
cache.soas_by_id[r["id"]] = r["token"]
|
||||
inserted += 1
|
||||
|
||||
skipped += len(batch) - len(rows)
|
||||
|
||||
if (i // BATCH_SIZE) % 10 == 0:
|
||||
log.info("wordnet import: %d / %d …", i + len(batch), total)
|
||||
|
||||
log.info(
|
||||
"wordnet import complete: %d inserted, %d already present, %d total",
|
||||
inserted, skipped, total,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"total_lemmas": total,
|
||||
"inserted": inserted,
|
||||
"already_present": skipped,
|
||||
"citation": CITATION,
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Async write queue — background processing of saliency-triggered and
|
||||
cue-triggered write requests.
|
||||
|
||||
Cloud LLM calls and URD inserts never block the proxy response path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import asyncpg
|
||||
|
||||
from .cache import SoasRow
|
||||
from .cue_scanner import CueTriple
|
||||
from .db import get_or_create_soas, get_config
|
||||
from .llm_client import (
|
||||
ModelConfig, get_model_config, call_llm,
|
||||
WRITE_PROMPT_TEMPLATE, parse_llm_triples,
|
||||
)
|
||||
from .urd_writer import InsertRequest, insert_urd_edge
|
||||
from . import cache
|
||||
|
||||
log = logging.getLogger("festinger.write_queue")
|
||||
|
||||
|
||||
@dataclass
|
||||
class WriteRequest:
|
||||
"""A concept that crossed the write threshold — needs LLM-assisted classification."""
|
||||
concept_token: str
|
||||
trigger: str = "saliency" # 'saliency' | 'cue'
|
||||
|
||||
|
||||
@dataclass
|
||||
class CueWriteRequest:
|
||||
"""A directly extracted CueTriple — bypasses LLM, goes straight to URD insert."""
|
||||
triple: CueTriple
|
||||
|
||||
|
||||
_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
|
||||
_running: bool = False
|
||||
|
||||
|
||||
async def enqueue_concept(token: str) -> None:
|
||||
try:
|
||||
_queue.put_nowait(WriteRequest(concept_token=token))
|
||||
except asyncio.QueueFull:
|
||||
log.warning("write queue full — dropping concept: %s", token)
|
||||
|
||||
|
||||
async def enqueue_cue(triple: CueTriple) -> None:
|
||||
try:
|
||||
_queue.put_nowait(CueWriteRequest(triple=triple))
|
||||
except asyncio.QueueFull:
|
||||
log.warning("write queue full — dropping cue: %s", triple)
|
||||
|
||||
|
||||
async def start_worker(pool: asyncpg.Pool) -> None:
|
||||
"""Launch background worker. Call once at startup."""
|
||||
global _running
|
||||
_running = True
|
||||
asyncio.create_task(_worker(pool))
|
||||
log.info("write queue worker started")
|
||||
|
||||
|
||||
async def _worker(pool: asyncpg.Pool) -> None:
|
||||
while _running:
|
||||
try:
|
||||
item = await asyncio.wait_for(_queue.get(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
try:
|
||||
if isinstance(item, CueWriteRequest):
|
||||
await _process_cue(pool, item.triple)
|
||||
elif isinstance(item, WriteRequest):
|
||||
await _process_concept(pool, item.concept_token)
|
||||
except Exception as e:
|
||||
log.exception("write queue worker error: %s", e)
|
||||
finally:
|
||||
_queue.task_done()
|
||||
|
||||
|
||||
async def stop_worker() -> None:
|
||||
global _running
|
||||
_running = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Processing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _process_cue(pool: asyncpg.Pool, triple: CueTriple) -> None:
|
||||
"""Insert a cue-extracted triple directly into URD."""
|
||||
subj_row = await get_or_create_soas(pool, triple.subject)
|
||||
parent_row = await get_or_create_soas(pool, triple.parent)
|
||||
dim_row = await get_or_create_soas(pool, triple.dimension)
|
||||
|
||||
req = InsertRequest(
|
||||
concept_id=subj_row.id,
|
||||
parent_id=parent_row.id,
|
||||
dim_id=dim_row.id,
|
||||
is_isa=triple.is_isa,
|
||||
confidence=triple.confidence,
|
||||
source="inferred",
|
||||
)
|
||||
collision = await insert_urd_edge(pool, req)
|
||||
if collision:
|
||||
log.info("cue triple collision: %s", collision)
|
||||
|
||||
|
||||
async def _process_concept(pool: asyncpg.Pool, concept_token: str) -> None:
|
||||
"""Call cloud LLM to classify the concept, then insert all returned triples."""
|
||||
write_model_id = await get_config(pool, "write_model_id")
|
||||
if not write_model_id:
|
||||
log.debug("no write_model_id configured — skipping LLM write for %s", concept_token)
|
||||
return
|
||||
|
||||
model = await get_model_config(pool, write_model_id)
|
||||
if not model:
|
||||
log.warning("write_model_id=%s not found in models table", write_model_id)
|
||||
return
|
||||
|
||||
known_dims = list(cache.soas_by_token.keys())
|
||||
# Keep only seed dimensions + short list for prompt brevity
|
||||
seed_dims = ["type", "membership", "runs-on", "tech", "owned-by", "geography"]
|
||||
dimensions_str = ", ".join(seed_dims)
|
||||
|
||||
prompt = WRITE_PROMPT_TEMPLATE.format(
|
||||
concept=concept_token,
|
||||
dimensions=dimensions_str,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await call_llm(model, prompt)
|
||||
except Exception as e:
|
||||
log.warning("LLM call failed for concept %s: %s", concept_token, e)
|
||||
return
|
||||
|
||||
triples = parse_llm_triples(response)
|
||||
if not triples:
|
||||
log.info("LLM returned no triples for concept: %s", concept_token)
|
||||
return
|
||||
|
||||
subj_row = await get_or_create_soas(pool, concept_token)
|
||||
|
||||
for t in triples:
|
||||
if not t.parent or not t.dimension:
|
||||
continue
|
||||
parent_row = await get_or_create_soas(pool, t.parent)
|
||||
dim_row = await get_or_create_soas(pool, t.dimension)
|
||||
|
||||
req = InsertRequest(
|
||||
concept_id=subj_row.id,
|
||||
parent_id=parent_row.id,
|
||||
dim_id=dim_row.id,
|
||||
is_isa=t.is_isa,
|
||||
confidence=t.confidence,
|
||||
source="cloud_llm",
|
||||
)
|
||||
await insert_urd_edge(pool, req)
|
||||
|
||||
# Mark concept as confirmed — set novelty=1.0
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE soas SET novelty = 1.0 WHERE id = $1", subj_row.id
|
||||
)
|
||||
if concept_token in cache.soas_by_token:
|
||||
cache.soas_by_token[concept_token].novelty = 1.0
|
||||
@@ -0,0 +1,6 @@
|
||||
name: festinger
|
||||
title: "Festinger"
|
||||
description: "Ollama-compatible inference middleware for Agent0. Detects and breaks reasoning loops. Maintains a structured, contradiction-resistant world model (Recollections) that enriches every prompt spontaneously. Named after Leon Festinger, who introduced the theory of cognitive dissonance."
|
||||
version: 0.1.0
|
||||
type: inference_proxy
|
||||
settings_sections: []
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Loop Detector Proxy — Agent0 Plugin
|
||||
|
||||
An Ollama-compatible HTTP proxy that intercepts LLM completions, detects
|
||||
repeated outputs, and applies configurable mitigations before returning the
|
||||
response to the caller (agent-zero).
|
||||
|
||||
Architecture:
|
||||
agent-zero → loop-detector:11434 → ollama:11434 (host)
|
||||
|
||||
POC limitations:
|
||||
- Forces stream=false on all forwarded requests to simplify response
|
||||
buffering. Streaming responses are re-emitted as single JSON objects,
|
||||
which agent-zero handles correctly for task execution.
|
||||
- State is in-memory; restarting the proxy clears all session history.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
from fastapi import FastAPI, Request, Response
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(message)s",
|
||||
)
|
||||
log = logging.getLogger("loop_detector")
|
||||
|
||||
CONFIG_PATH = Path(__file__).parent / "config.yaml"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_config() -> dict:
|
||||
with open(CONFIG_PATH) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session state
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key: (model, session_fingerprint) → deque of response hashes
|
||||
_history: dict[tuple, deque] = defaultdict(lambda: deque(maxlen=20))
|
||||
# Key: same tuple → current consecutive-repeat count
|
||||
_consecutive: dict[tuple, int] = defaultdict(int)
|
||||
_last_hash: dict[tuple, str] = {}
|
||||
|
||||
|
||||
def _session_key(model: str, messages: list[dict]) -> tuple[str, str]:
|
||||
"""
|
||||
Derive a stable session identifier from the model name and the content
|
||||
of the first system message (which is unique per agent profile).
|
||||
Falls back to a hash of all non-assistant messages if there is no system
|
||||
message.
|
||||
"""
|
||||
system_msgs = [m for m in messages if m.get("role") == "system"]
|
||||
if system_msgs:
|
||||
fingerprint = hashlib.sha256(
|
||||
system_msgs[0].get("content", "")[:512].encode()
|
||||
).hexdigest()[:16]
|
||||
else:
|
||||
non_assistant = [m for m in messages if m.get("role") != "assistant"]
|
||||
blob = json.dumps(non_assistant, sort_keys=True)
|
||||
fingerprint = hashlib.sha256(blob.encode()).hexdigest()[:16]
|
||||
return (model, fingerprint)
|
||||
|
||||
|
||||
def _hash_response(text: str) -> str:
|
||||
return hashlib.sha256(text.strip().encode()).hexdigest()
|
||||
|
||||
|
||||
def record_and_check(session: tuple, text: str, min_length: int) -> int:
|
||||
"""
|
||||
Record a completion and return the current consecutive-repeat count.
|
||||
Returns 1 (no loop) if the text is below min_length.
|
||||
"""
|
||||
if len(text.strip()) < min_length:
|
||||
return 1
|
||||
|
||||
h = _hash_response(text)
|
||||
history = _history[session]
|
||||
|
||||
if _last_hash.get(session) == h:
|
||||
_consecutive[session] += 1
|
||||
else:
|
||||
_consecutive[session] = 1
|
||||
_last_hash[session] = h
|
||||
|
||||
history.append(h)
|
||||
return _consecutive[session]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mitigations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def apply_mitigations(
|
||||
request_body: dict,
|
||||
count: int,
|
||||
config: dict,
|
||||
) -> tuple[dict, str | None]:
|
||||
"""
|
||||
Walk the configured mitigation list in order.
|
||||
Returns (modified_request_body, override_response_text_or_None).
|
||||
An override_response means "return this to the caller, skip the LLM".
|
||||
"""
|
||||
mitigations = config.get("mitigations", [])
|
||||
override: str | None = None
|
||||
|
||||
for m in mitigations:
|
||||
if not m.get("enabled", True):
|
||||
continue
|
||||
if count < m.get("trigger_count", 2):
|
||||
continue
|
||||
|
||||
strategy = m["strategy"]
|
||||
|
||||
if strategy == "temperature_boost":
|
||||
opts = request_body.setdefault("options", {})
|
||||
current = opts.get("temperature", 0.7)
|
||||
boosted = min(current + m.get("boost_amount", 0.35), m.get("max_temperature", 1.4))
|
||||
opts["temperature"] = boosted
|
||||
log.warning("mitigation=temperature_boost %.2f → %.2f (count=%d)", current, boosted, count)
|
||||
|
||||
elif strategy == "forbidden_action":
|
||||
msg = m.get("injection_message", "STOP. Try something completely different.").format(count=count)
|
||||
request_body.setdefault("messages", []).append({"role": "user", "content": msg})
|
||||
log.warning("mitigation=forbidden_action injected (count=%d)", count)
|
||||
|
||||
elif strategy == "history_truncation":
|
||||
messages = request_body.get("messages", [])
|
||||
truncate = m.get("truncate_turns", 6)
|
||||
system_msgs = [m_ for m_ in messages if m_.get("role") == "system"]
|
||||
non_system = [m_ for m_ in messages if m_.get("role") != "system"]
|
||||
# Keep at least the most recent exchange after truncation
|
||||
trimmed = non_system[:-truncate] if len(non_system) > truncate else non_system[-2:]
|
||||
request_body["messages"] = system_msgs + trimmed
|
||||
log.warning("mitigation=history_truncation dropped %d turns (count=%d)", truncate, count)
|
||||
|
||||
elif strategy == "circuit_breaker":
|
||||
override = m.get("response_message", "[LOOP DETECTOR] Halted due to repeated responses.")
|
||||
log.error("mitigation=circuit_breaker TRIGGERED (count=%d)", count)
|
||||
break # nothing further makes sense
|
||||
|
||||
return request_body, override
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ollama forwarding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def call_ollama(path: str, body: dict, upstream: str) -> tuple[str, dict]:
|
||||
"""
|
||||
Forward a request to upstream Ollama (stream=False) and return
|
||||
(assistant_text, raw_response_dict).
|
||||
"""
|
||||
body = dict(body)
|
||||
body["stream"] = False
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
r = await client.post(f"{upstream}{path}", json=body)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
if path == "/api/chat":
|
||||
text = data.get("message", {}).get("content", "")
|
||||
else:
|
||||
text = data.get("response", "")
|
||||
|
||||
return text, data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI app
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(title="Loop Detector Proxy")
|
||||
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def chat(request: Request) -> Response:
|
||||
config = load_config()
|
||||
body = await request.json()
|
||||
model = body.get("model", "unknown")
|
||||
upstream = config["upstream_ollama"]
|
||||
min_len = config["detection"]["min_length"]
|
||||
|
||||
text, raw = await call_ollama("/api/chat", body, upstream)
|
||||
session = _session_key(model, body.get("messages", []))
|
||||
count = record_and_check(session, text, min_len)
|
||||
|
||||
if count >= 2:
|
||||
log.warning("loop detected model=%s session=%s count=%d", model, session[1], count)
|
||||
body, override = apply_mitigations(body, count, config)
|
||||
|
||||
if override is not None:
|
||||
raw["message"] = {"role": "assistant", "content": override}
|
||||
raw["loop_detected"] = True
|
||||
return Response(content=json.dumps(raw), media_type="application/json")
|
||||
|
||||
# Retry with mitigations applied
|
||||
text, raw = await call_ollama("/api/chat", body, upstream)
|
||||
record_and_check(session, text, min_len)
|
||||
|
||||
raw["message"] = {"role": "assistant", "content": text}
|
||||
return Response(content=json.dumps(raw), media_type="application/json")
|
||||
|
||||
|
||||
@app.post("/api/generate")
|
||||
async def generate(request: Request) -> Response:
|
||||
config = load_config()
|
||||
body = await request.json()
|
||||
model = body.get("model", "unknown")
|
||||
upstream = config["upstream_ollama"]
|
||||
min_len = config["detection"]["min_length"]
|
||||
|
||||
# /api/generate has no messages list; use prompt as fingerprint
|
||||
messages = [{"role": "user", "content": body.get("prompt", "")}]
|
||||
session = _session_key(model, messages)
|
||||
|
||||
text, raw = await call_ollama("/api/generate", body, upstream)
|
||||
count = record_and_check(session, text, min_len)
|
||||
|
||||
if count >= 2:
|
||||
log.warning("loop detected model=%s session=%s count=%d", model, session[1], count)
|
||||
body, override = apply_mitigations(body, count, config)
|
||||
|
||||
if override is not None:
|
||||
raw["response"] = override
|
||||
raw["loop_detected"] = True
|
||||
return Response(content=json.dumps(raw), media_type="application/json")
|
||||
|
||||
text, raw = await call_ollama("/api/generate", body, upstream)
|
||||
record_and_check(session, text, min_len)
|
||||
|
||||
raw["response"] = text
|
||||
return Response(content=json.dumps(raw), media_type="application/json")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
cfg = load_config()
|
||||
return {
|
||||
"status": "ok",
|
||||
"upstream": cfg["upstream_ollama"],
|
||||
"active_sessions": len(_history),
|
||||
"timestamp": int(time.time()),
|
||||
}
|
||||
|
||||
|
||||
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "HEAD"])
|
||||
async def passthrough(path: str, request: Request) -> Response:
|
||||
"""Forward anything we don't handle (model listing, embeddings, etc.) straight through."""
|
||||
config = load_config()
|
||||
upstream = config["upstream_ollama"]
|
||||
|
||||
body = await request.body()
|
||||
headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
r = await client.request(
|
||||
request.method,
|
||||
f"{upstream}/{path}",
|
||||
content=body,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=r.content,
|
||||
status_code=r.status_code,
|
||||
media_type=r.headers.get("content-type"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
cfg = load_config()
|
||||
uvicorn.run(app, host="0.0.0.0", port=cfg["proxy_port"], log_level="info")
|
||||
@@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
@@ -0,0 +1,10 @@
|
||||
fastapi==0.128.8
|
||||
uvicorn==0.39.0
|
||||
httpx==0.28.1
|
||||
pyyaml==6.0.3
|
||||
asyncpg==0.31.0
|
||||
anthropic==0.96.0
|
||||
openai==2.32.0
|
||||
apscheduler==3.11.2
|
||||
pytest==8.4.0
|
||||
pytest-asyncio==0.26.0
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Test helpers — populate the in-memory cache directly without touching Postgres.
|
||||
|
||||
All collision detection, recollection rendering, and queue routing logic operates
|
||||
entirely on the module-level dicts in festinger.cache, so unit tests can inject
|
||||
state there directly and assert outcomes without a live database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from festinger import cache
|
||||
from festinger.cache import SoasRow, UrdEdge
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Clear all in-memory state. Call at the start of each test."""
|
||||
cache.soas_by_token.clear()
|
||||
cache.soas_by_id.clear()
|
||||
cache.urd_by_concept.clear()
|
||||
cache.urd_by_concept_dim.clear()
|
||||
cache.pending_conflicts.clear()
|
||||
cache._encounter_deltas.clear()
|
||||
|
||||
|
||||
def add_soas(id: int, token: str, saliency: float = 0.0, novelty: float = 0.0) -> SoasRow:
|
||||
row = SoasRow(id=id, token=token, saliency=saliency, novelty=novelty)
|
||||
cache.soas_by_token[token] = row
|
||||
cache.soas_by_id[id] = token
|
||||
return row
|
||||
|
||||
|
||||
def add_urd(
|
||||
concept_id: int,
|
||||
parent_id: int,
|
||||
dim_id: int,
|
||||
is_isa: bool,
|
||||
confidence: float = 0.9,
|
||||
source: str = "test",
|
||||
) -> UrdEdge:
|
||||
parent_token = cache.soas_by_id.get(parent_id, str(parent_id))
|
||||
dim_token = cache.soas_by_id.get(dim_id, str(dim_id))
|
||||
edge = UrdEdge(
|
||||
concept_id=concept_id,
|
||||
parent_id=parent_id,
|
||||
dim_id=dim_id,
|
||||
is_isa=is_isa,
|
||||
confidence=confidence,
|
||||
source=source,
|
||||
parent_token=parent_token,
|
||||
dim_token=dim_token,
|
||||
)
|
||||
cache.urd_by_concept.setdefault(concept_id, []).append(edge)
|
||||
cache.urd_by_concept_dim[(concept_id, dim_id)] = edge
|
||||
return edge
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Collision detection tests — exercises the in-memory URD insert pipeline.
|
||||
|
||||
All tests inject state directly into the cache module dicts and call
|
||||
insert_urd_edge() with a mock asyncpg pool that records calls but never
|
||||
touches a real database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from festinger import cache
|
||||
from festinger.urd_writer import InsertRequest, insert_urd_edge, CollisionInfo
|
||||
from tests.helpers import reset_cache, add_soas, add_urd
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock pool — captures INSERT attempts, never hits Postgres
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_mock_pool():
|
||||
"""Return a mock asyncpg pool where execute() succeeds and never raises."""
|
||||
conn = AsyncMock()
|
||||
conn.execute = AsyncMock(return_value="INSERT 0 1")
|
||||
conn.fetchrow = AsyncMock(return_value=None)
|
||||
conn.__aenter__ = AsyncMock(return_value=conn)
|
||||
conn.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
pool = MagicMock()
|
||||
pool.acquire = MagicMock(return_value=conn)
|
||||
return pool, conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario A — misclassification (ISPART existing, ISA incoming, same dim)
|
||||
#
|
||||
# Pre-state: michigan ISPART usa in dim:usa (parent_id = dim_id — degenerate edge)
|
||||
# Trigger: michigan ISA state in dim:usa
|
||||
# Expected: misclassification collision, no URD modification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScenarioA:
|
||||
|
||||
def setup_method(self):
|
||||
reset_cache()
|
||||
# SOAS entries
|
||||
self.michigan = add_soas(101, "michigan", saliency=1.5)
|
||||
self.usa = add_soas(102, "usa", saliency=0.8)
|
||||
self.state = add_soas(103, "state", saliency=0.7)
|
||||
|
||||
# Degenerate seed edge: michigan ISPART usa, dim=usa (parent_id = dim_id)
|
||||
self.existing = add_urd(
|
||||
concept_id=101, parent_id=102, dim_id=102, # dim_id = parent_id = usa
|
||||
is_isa=False, confidence=0.85, source="test"
|
||||
)
|
||||
|
||||
def test_degenerate_edge_stored(self):
|
||||
"""Confirm the seed has parent_id == dim_id."""
|
||||
edge = cache.urd_by_concept_dim.get((101, 102))
|
||||
assert edge is not None
|
||||
assert edge.parent_id == edge.dim_id == 102
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_misclassification_detected(self):
|
||||
"""Incoming ISA in the same dim as existing ISPART → misclassification."""
|
||||
pool, conn = make_mock_pool()
|
||||
|
||||
# Patch _queue_collision so we can inspect it without hitting Postgres
|
||||
queued: list[CollisionInfo] = []
|
||||
async def fake_queue(pool, col, priority):
|
||||
queued.append(col)
|
||||
cache.pending_conflicts.add(col.concept_id)
|
||||
|
||||
with patch("festinger.urd_writer._queue_collision", side_effect=fake_queue):
|
||||
req = InsertRequest(
|
||||
concept_id=101, # michigan
|
||||
parent_id=103, # state
|
||||
dim_id=102, # usa (same dim as existing ISPART)
|
||||
is_isa=True,
|
||||
confidence=0.85,
|
||||
source="gutask",
|
||||
)
|
||||
collision = await insert_urd_edge(pool, req)
|
||||
|
||||
assert collision is not None
|
||||
assert collision.collision_type == "misclassification"
|
||||
assert collision.existing_is_isa is False
|
||||
assert collision.incoming_is_isa is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_urd_not_modified_on_collision(self):
|
||||
"""URD in-memory cache is unchanged after a collision."""
|
||||
pool, conn = make_mock_pool()
|
||||
|
||||
with patch("festinger.urd_writer._queue_collision", new_callable=AsyncMock):
|
||||
req = InsertRequest(
|
||||
concept_id=101, parent_id=103, dim_id=102,
|
||||
is_isa=True, confidence=0.85, source="gutask",
|
||||
)
|
||||
await insert_urd_edge(pool, req)
|
||||
|
||||
# Original edge still in place
|
||||
edge = cache.urd_by_concept_dim.get((101, 102))
|
||||
assert edge is not None
|
||||
assert edge.parent_id == 102 # usa, unchanged
|
||||
assert edge.is_isa is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgres_not_written_on_collision(self):
|
||||
"""No INSERT to Postgres is attempted when collision is detected in cache."""
|
||||
pool, conn = make_mock_pool()
|
||||
|
||||
with patch("festinger.urd_writer._queue_collision", new_callable=AsyncMock):
|
||||
req = InsertRequest(
|
||||
concept_id=101, parent_id=103, dim_id=102,
|
||||
is_isa=True, confidence=0.85, source="gutask",
|
||||
)
|
||||
await insert_urd_edge(pool, req)
|
||||
|
||||
conn.execute.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_conflicts_marked(self):
|
||||
"""concept_id is added to pending_conflicts after collision."""
|
||||
pool, _ = make_mock_pool()
|
||||
|
||||
async def fake_queue(pool, col, priority):
|
||||
cache.pending_conflicts.add(col.concept_id)
|
||||
|
||||
with patch("festinger.urd_writer._queue_collision", side_effect=fake_queue):
|
||||
req = InsertRequest(
|
||||
concept_id=101, parent_id=103, dim_id=102,
|
||||
is_isa=True, confidence=0.85, source="gutask",
|
||||
)
|
||||
await insert_urd_edge(pool, req)
|
||||
|
||||
assert 101 in cache.pending_conflicts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario B — ISA + ISA collision (dimension too coarse → decompose)
|
||||
#
|
||||
# Pre-state: gnommoweb ISA container in dim:type
|
||||
# Trigger: gnommoweb ISA repo in dim:type
|
||||
# Expected: isa_isa collision
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScenarioB:
|
||||
|
||||
def setup_method(self):
|
||||
reset_cache()
|
||||
self.gnommoweb = add_soas(201, "gnommoweb", saliency=1.5)
|
||||
self.container = add_soas(202, "container", saliency=0.9)
|
||||
self.repo = add_soas(203, "repo", saliency=0.8)
|
||||
self.type_dim = add_soas(1, "type", saliency=0.0)
|
||||
|
||||
self.existing = add_urd(
|
||||
concept_id=201, parent_id=202, dim_id=1,
|
||||
is_isa=True, confidence=0.9, source="cloud_llm"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_isa_isa_collision_type(self):
|
||||
pool, _ = make_mock_pool()
|
||||
queued: list[CollisionInfo] = []
|
||||
|
||||
async def fake_queue(pool, col, priority):
|
||||
queued.append(col)
|
||||
cache.pending_conflicts.add(col.concept_id)
|
||||
|
||||
with patch("festinger.urd_writer._queue_collision", side_effect=fake_queue):
|
||||
req = InsertRequest(
|
||||
concept_id=201, parent_id=203, dim_id=1,
|
||||
is_isa=True, confidence=0.85, source="gutask",
|
||||
)
|
||||
collision = await insert_urd_edge(pool, req)
|
||||
|
||||
assert collision is not None
|
||||
assert collision.collision_type == "isa_isa"
|
||||
assert len(queued) == 1
|
||||
assert queued[0].existing_parent_id == 202 # container
|
||||
assert queued[0].incoming_parent_id == 203 # repo
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_edge_unchanged_after_isa_isa(self):
|
||||
pool, _ = make_mock_pool()
|
||||
|
||||
with patch("festinger.urd_writer._queue_collision", new_callable=AsyncMock):
|
||||
req = InsertRequest(
|
||||
concept_id=201, parent_id=203, dim_id=1,
|
||||
is_isa=True, confidence=0.85, source="gutask",
|
||||
)
|
||||
await insert_urd_edge(pool, req)
|
||||
|
||||
edge = cache.urd_by_concept_dim.get((201, 1))
|
||||
assert edge.parent_id == 202 # still container
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario C — ISPART + ISPART contradiction (host migration)
|
||||
#
|
||||
# Pre-state: dobby ISPART docker_host_1 in dim:runs-on
|
||||
# Trigger: dobby ISPART docker_host_2 in dim:runs-on
|
||||
# Expected: ispart_ispart collision
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScenarioC:
|
||||
|
||||
def setup_method(self):
|
||||
reset_cache()
|
||||
self.dobby = add_soas(301, "dobby", saliency=1.5)
|
||||
self.host1 = add_soas(302, "docker_host_1", saliency=0.5)
|
||||
self.host2 = add_soas(303, "docker_host_2", saliency=0.5)
|
||||
self.runs_on_dim = add_soas(4, "runs-on", saliency=0.0)
|
||||
|
||||
self.existing = add_urd(
|
||||
concept_id=301, parent_id=302, dim_id=4,
|
||||
is_isa=False, confidence=0.9, source="cloud_llm"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ispart_ispart_collision_type(self):
|
||||
pool, _ = make_mock_pool()
|
||||
queued: list[CollisionInfo] = []
|
||||
|
||||
async def fake_queue(pool, col, priority):
|
||||
queued.append(col)
|
||||
|
||||
with patch("festinger.urd_writer._queue_collision", side_effect=fake_queue):
|
||||
req = InsertRequest(
|
||||
concept_id=301, parent_id=303, dim_id=4,
|
||||
is_isa=False, confidence=1.0, source="gutask",
|
||||
)
|
||||
collision = await insert_urd_edge(pool, req)
|
||||
|
||||
assert collision is not None
|
||||
assert collision.collision_type == "ispart_ispart"
|
||||
assert queued[0].existing_parent_id == 302 # docker_host_1
|
||||
assert queued[0].incoming_parent_id == 303 # docker_host_2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Clean insert (no collision)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCleanInsert:
|
||||
|
||||
def setup_method(self):
|
||||
reset_cache()
|
||||
add_soas(401, "festinger", saliency=1.2)
|
||||
add_soas(402, "middleware", saliency=0.8)
|
||||
add_soas(1, "type", saliency=0.0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_insert_updates_cache(self):
|
||||
pool, conn = make_mock_pool()
|
||||
|
||||
req = InsertRequest(
|
||||
concept_id=401, parent_id=402, dim_id=1,
|
||||
is_isa=True, confidence=0.9, source="test",
|
||||
)
|
||||
collision = await insert_urd_edge(pool, req)
|
||||
|
||||
assert collision is None
|
||||
edge = cache.urd_by_concept_dim.get((401, 1))
|
||||
assert edge is not None
|
||||
assert edge.parent_id == 402
|
||||
assert edge.is_isa is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_insert_calls_postgres(self):
|
||||
pool, conn = make_mock_pool()
|
||||
|
||||
req = InsertRequest(
|
||||
concept_id=401, parent_id=402, dim_id=1,
|
||||
is_isa=True, confidence=0.9, source="test",
|
||||
)
|
||||
await insert_urd_edge(pool, req)
|
||||
|
||||
conn.execute.assert_called_once()
|
||||
call_args = conn.execute.call_args[0]
|
||||
assert "INSERT INTO urd" in call_args[0]
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for the relationship cue scanner — ISA/ISPART patterns and of-Z modifier."""
|
||||
import pytest
|
||||
from festinger.cue_scanner import scan_cues
|
||||
|
||||
|
||||
def _find(triples, subj, parent, dim=None, is_isa=None):
|
||||
for t in triples:
|
||||
if t.subject != subj or t.parent != parent:
|
||||
continue
|
||||
if dim is not None and t.dimension != dim:
|
||||
continue
|
||||
if is_isa is not None and t.is_isa != is_isa:
|
||||
continue
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ISA patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_is_a_pattern():
|
||||
triples = scan_cues("gnommoweb is a repo")
|
||||
t = _find(triples, "gnommoweb", "repo", is_isa=True)
|
||||
assert t is not None
|
||||
assert t.dimension == "type"
|
||||
|
||||
|
||||
def test_is_an_pattern():
|
||||
triples = scan_cues("gnommoweb is an api")
|
||||
t = _find(triples, "gnommoweb", "api", is_isa=True)
|
||||
assert t is not None
|
||||
|
||||
|
||||
def test_isa_explicit():
|
||||
triples = scan_cues("gnommoweb ISA repo")
|
||||
t = _find(triples, "gnommoweb", "repo", is_isa=True)
|
||||
assert t is not None
|
||||
assert t.confidence >= 0.9
|
||||
|
||||
|
||||
def test_of_z_dimension_modifier():
|
||||
# "is a repo of Glitch University" → dim = glitch_university
|
||||
triples = scan_cues("gnommoweb is a repo of Glitch University")
|
||||
t = _find(triples, "gnommoweb", "repo", is_isa=True)
|
||||
assert t is not None
|
||||
assert t.dimension == "glitch_university"
|
||||
|
||||
|
||||
def test_is_a_state_of_usa():
|
||||
# Core scenario A trigger pattern
|
||||
triples = scan_cues("michigan is a state of USA")
|
||||
t = _find(triples, "michigan", "state", is_isa=True)
|
||||
assert t is not None
|
||||
assert t.dimension == "usa"
|
||||
|
||||
|
||||
def test_is_a_kind_of():
|
||||
triples = scan_cues("gnommoweb is a kind of service")
|
||||
t = _find(triples, "gnommoweb", "service", is_isa=True)
|
||||
assert t is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ISPART patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_is_part_of():
|
||||
triples = scan_cues("gnommoweb is part of Glitch University")
|
||||
t = _find(triples, "gnommoweb", "glitch_university", is_isa=False)
|
||||
assert t is not None
|
||||
|
||||
|
||||
def test_runs_on():
|
||||
triples = scan_cues("gnommoweb runs on Docker")
|
||||
t = _find(triples, "gnommoweb", "docker", is_isa=False)
|
||||
assert t is not None
|
||||
assert t.dimension == "runs-on"
|
||||
|
||||
|
||||
def test_belongs_to():
|
||||
triples = scan_cues("gnommoweb belongs to Agent0")
|
||||
t = _find(triples, "gnommoweb", "agent0", is_isa=False)
|
||||
assert t is not None
|
||||
|
||||
|
||||
def test_is_owned_by():
|
||||
triples = scan_cues("gnommoweb is owned by jenstandstad")
|
||||
t = _find(triples, "gnommoweb", "jenstandstad", is_isa=False)
|
||||
assert t is not None
|
||||
assert t.dimension == "owned-by"
|
||||
|
||||
|
||||
def test_deployed_on():
|
||||
triples = scan_cues("dobby deployed on docker_host_2")
|
||||
t = _find(triples, "dobby", "docker_host_2", is_isa=False)
|
||||
assert t is not None
|
||||
assert t.dimension == "runs-on"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No false positives
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_no_match_plain_sentence():
|
||||
triples = scan_cues("please update the configuration file")
|
||||
assert triples == []
|
||||
|
||||
|
||||
def test_deduplication():
|
||||
# Same triple from multiple overlapping patterns should appear once
|
||||
triples = scan_cues("gnommoweb is a repo")
|
||||
matches = [t for t in triples if t.subject == "gnommoweb" and t.parent == "repo"]
|
||||
# May match both "is a" and "is an" patterns but should deduplicate
|
||||
assert len(matches) <= 2 # at most one per distinct pattern type
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Recollection rendering tests — hit path, zero-hit path, pending-conflict marker,
|
||||
and prompt injection position.
|
||||
"""
|
||||
import pytest
|
||||
from festinger import cache
|
||||
from festinger.recollection import (
|
||||
query_edges, render_hit, render_zero_hit,
|
||||
build_recollection_block, inject_recollection,
|
||||
)
|
||||
from tests.helpers import reset_cache, add_soas, add_urd
|
||||
|
||||
|
||||
class TestHitPath:
|
||||
|
||||
def setup_method(self):
|
||||
reset_cache()
|
||||
self.michigan = add_soas(101, "michigan", saliency=1.5)
|
||||
self.usa = add_soas(102, "usa", saliency=0.8)
|
||||
self.geography = add_soas(5, "geography", saliency=0.0)
|
||||
self.state = add_soas(103, "state", saliency=0.7)
|
||||
self.type_dim = add_soas(1, "type", saliency=0.0)
|
||||
|
||||
add_urd(concept_id=101, parent_id=102, dim_id=5, is_isa=False, confidence=0.9)
|
||||
add_urd(concept_id=101, parent_id=103, dim_id=1, is_isa=True, confidence=0.9)
|
||||
|
||||
def test_query_returns_both_edges(self):
|
||||
edges = query_edges(101, confidence_floor=0.5, recency_days=90)
|
||||
assert len(edges) == 2
|
||||
|
||||
def test_render_hit_format(self):
|
||||
edges = query_edges(101, confidence_floor=0.5, recency_days=90)
|
||||
line = render_hit("michigan", edges, concept_id=101)
|
||||
assert line.startswith("michigan:")
|
||||
assert "[geography] usa" in line
|
||||
assert "[type] state" in line
|
||||
|
||||
def test_pending_conflict_adds_question_mark(self):
|
||||
cache.pending_conflicts.add(101)
|
||||
edges = query_edges(101, confidence_floor=0.5, recency_days=90)
|
||||
line = render_hit("michigan", edges, concept_id=101)
|
||||
# All dim labels should have ? when concept has pending conflict
|
||||
assert "[geography?]" in line or "[type?]" in line
|
||||
|
||||
def test_confidence_floor_filters_edges(self):
|
||||
# Add a low-confidence edge
|
||||
add_urd(concept_id=101, parent_id=5, dim_id=5, is_isa=False, confidence=0.2)
|
||||
edges = query_edges(101, confidence_floor=0.6, recency_days=90)
|
||||
# Should not include the 0.2 confidence edge
|
||||
low_conf = [e for e in edges if e.confidence < 0.6]
|
||||
assert low_conf == []
|
||||
|
||||
def test_block_contains_recollection_tags(self):
|
||||
block = build_recollection_block([101], confidence_floor=0.5, recency_days=90)
|
||||
assert block is not None
|
||||
assert block.startswith("<recollection>")
|
||||
assert block.endswith("</recollection>")
|
||||
|
||||
|
||||
class TestZeroHitPath:
|
||||
|
||||
def setup_method(self):
|
||||
reset_cache()
|
||||
add_soas(201, "ramanujan", saliency=1.5)
|
||||
# No URD edges for ramanujan
|
||||
|
||||
def test_zero_hit_render(self):
|
||||
line = render_zero_hit("ramanujan")
|
||||
assert "ramanujan" in line
|
||||
assert "no recollection" in line
|
||||
assert "gutask iknowthat" in line
|
||||
|
||||
def test_zero_hit_in_block(self):
|
||||
block = build_recollection_block([201], confidence_floor=0.5, recency_days=90)
|
||||
assert block is not None
|
||||
assert "? ramanujan" in block
|
||||
assert "gutask iknowthat" in block
|
||||
|
||||
def test_block_is_none_when_no_salient_concepts(self):
|
||||
block = build_recollection_block([], confidence_floor=0.5, recency_days=90)
|
||||
assert block is None
|
||||
|
||||
|
||||
class TestPromptInjection:
|
||||
|
||||
def setup_method(self):
|
||||
reset_cache()
|
||||
add_soas(101, "michigan", saliency=1.5)
|
||||
add_soas(102, "usa", saliency=0.8)
|
||||
add_soas(5, "geography", saliency=0.0)
|
||||
add_urd(concept_id=101, parent_id=102, dim_id=5, is_isa=False, confidence=0.9)
|
||||
|
||||
def _block(self):
|
||||
return build_recollection_block([101], confidence_floor=0.5, recency_days=90)
|
||||
|
||||
def test_injected_into_existing_system_message(self):
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Tell me about michigan."},
|
||||
]
|
||||
block = self._block()
|
||||
result = inject_recollection(messages, block)
|
||||
|
||||
system = next(m for m in result if m["role"] == "system")
|
||||
assert system["content"].startswith("<recollection>")
|
||||
assert "You are a helpful assistant." in system["content"]
|
||||
|
||||
def test_injected_at_position_0_when_no_system_message(self):
|
||||
messages = [{"role": "user", "content": "Tell me about michigan."}]
|
||||
block = self._block()
|
||||
result = inject_recollection(messages, block)
|
||||
|
||||
assert result[0]["role"] == "system"
|
||||
assert "<recollection>" in result[0]["content"]
|
||||
assert result[1]["role"] == "user"
|
||||
|
||||
def test_original_messages_not_mutated(self):
|
||||
original = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "michigan?"},
|
||||
]
|
||||
inject_recollection(original, self._block())
|
||||
# Original list and dicts must be unchanged
|
||||
assert original[0]["content"] == "You are helpful."
|
||||
|
||||
def test_user_message_preserved_after_injection(self):
|
||||
messages = [
|
||||
{"role": "system", "content": "System prompt."},
|
||||
{"role": "user", "content": "michigan?"},
|
||||
]
|
||||
result = inject_recollection(messages, self._block())
|
||||
user_msgs = [m for m in result if m["role"] == "user"]
|
||||
assert user_msgs[0]["content"] == "michigan?"
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Scenario A — full integration walk-through (in-memory only, no DB required).
|
||||
|
||||
Demonstrates the degenerate parent_id=dim_id pattern and the misclassification
|
||||
collision pipeline from initial seed through recollection rendering.
|
||||
|
||||
Story:
|
||||
1. World model receives coarse early knowledge: "michigan is in usa"
|
||||
→ stored as michigan ISPART usa in dim:usa (parent_id = dim_id)
|
||||
2. Agent sends prompt: "michigan is a state of USA"
|
||||
→ cue scanner extracts michigan ISA state in dim:usa
|
||||
3. Collision detected: existing ISPART vs incoming ISA in dim:usa → misclassification
|
||||
4. Recollection rendered with [usa?] marker while conflict is pending
|
||||
5. After resolution (simulated): fact moves to correct dimension
|
||||
→ michigan ISA state in dim:type
|
||||
→ michigan ISPART usa in dim:geography
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from festinger import cache
|
||||
from festinger.cache import SoasRow, UrdEdge
|
||||
from festinger.cue_scanner import scan_cues
|
||||
from festinger.recollection import (
|
||||
build_recollection_block, inject_recollection, render_hit, query_edges
|
||||
)
|
||||
from festinger.urd_writer import InsertRequest, insert_urd_edge, CollisionInfo
|
||||
from tests.helpers import reset_cache, add_soas, add_urd
|
||||
|
||||
|
||||
def make_mock_pool():
|
||||
conn = AsyncMock()
|
||||
conn.execute = AsyncMock(return_value="INSERT 0 1")
|
||||
conn.fetchrow = AsyncMock(return_value=None)
|
||||
conn.__aenter__ = AsyncMock(return_value=conn)
|
||||
conn.__aexit__ = AsyncMock(return_value=False)
|
||||
pool = MagicMock()
|
||||
pool.acquire = MagicMock(return_value=conn)
|
||||
return pool, conn
|
||||
|
||||
|
||||
class TestScenarioAIntegration:
|
||||
|
||||
def setup_method(self):
|
||||
reset_cache()
|
||||
# SOAS vocabulary
|
||||
self.michigan = add_soas(101, "michigan", saliency=1.5, novelty=1.0)
|
||||
self.usa = add_soas(102, "usa", saliency=0.8, novelty=0.8)
|
||||
self.state = add_soas(103, "state", saliency=0.6, novelty=0.5)
|
||||
self.type_dim = add_soas(1, "type", saliency=0.0)
|
||||
self.geo_dim = add_soas(5, "geography", saliency=0.0)
|
||||
|
||||
# Degenerate seed edge: michigan ISPART usa, dim=usa (parent_id = dim_id = 102)
|
||||
self.seed_edge = add_urd(
|
||||
concept_id=101,
|
||||
parent_id=102, # usa
|
||||
dim_id=102, # usa — same as parent_id
|
||||
is_isa=False,
|
||||
confidence=0.85,
|
||||
source="test",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 1: Verify the degenerate seed state
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_seed_edge_has_parent_id_equals_dim_id(self):
|
||||
edge = cache.urd_by_concept_dim.get((101, 102))
|
||||
assert edge is not None, "seed edge must be in cache"
|
||||
assert edge.parent_id == edge.dim_id, (
|
||||
f"degenerate edge requires parent_id == dim_id, "
|
||||
f"got parent_id={edge.parent_id}, dim_id={edge.dim_id}"
|
||||
)
|
||||
assert edge.is_isa is False
|
||||
|
||||
def test_seed_recollection_renders_correctly(self):
|
||||
edges = query_edges(101, confidence_floor=0.5, recency_days=90)
|
||||
assert len(edges) == 1
|
||||
line = render_hit("michigan", edges, concept_id=101)
|
||||
# dim token = "usa", parent token = "usa"
|
||||
assert "[usa] usa" in line
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 2: Cue scanner extracts the incoming ISA triple
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_cue_scanner_extracts_michigan_isa_state(self):
|
||||
prompt = "michigan is a state of USA"
|
||||
triples = scan_cues(prompt)
|
||||
match = next(
|
||||
(t for t in triples if t.subject == "michigan" and t.parent == "state"),
|
||||
None,
|
||||
)
|
||||
assert match is not None, "cue scanner must extract michigan ISA state"
|
||||
assert match.is_isa is True
|
||||
assert match.dimension == "usa" # from "of USA" modifier
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 3: Incoming ISA in dim:usa collides with existing ISPART in dim:usa
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collision_classified_as_misclassification(self):
|
||||
pool, _ = make_mock_pool()
|
||||
captured: list[CollisionInfo] = []
|
||||
|
||||
async def fake_queue(pool, col, priority):
|
||||
captured.append(col)
|
||||
cache.pending_conflicts.add(col.concept_id)
|
||||
|
||||
with patch("festinger.urd_writer._queue_collision", side_effect=fake_queue):
|
||||
req = InsertRequest(
|
||||
concept_id=101, # michigan
|
||||
parent_id=103, # state
|
||||
dim_id=102, # usa — same dim as existing ISPART
|
||||
is_isa=True,
|
||||
confidence=0.85,
|
||||
source="gutask",
|
||||
)
|
||||
collision = await insert_urd_edge(pool, req)
|
||||
|
||||
assert collision is not None
|
||||
assert collision.collision_type == "misclassification", (
|
||||
f"expected misclassification, got {collision.collision_type!r}. "
|
||||
f"existing is_isa={self.seed_edge.is_isa}, incoming is_isa=True"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 4: Recollection renders the [usa?] pending-conflict marker
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recollection_shows_pending_marker_after_collision(self):
|
||||
pool, _ = make_mock_pool()
|
||||
|
||||
async def fake_queue(pool, col, priority):
|
||||
cache.pending_conflicts.add(col.concept_id)
|
||||
|
||||
with patch("festinger.urd_writer._queue_collision", side_effect=fake_queue):
|
||||
req = InsertRequest(
|
||||
concept_id=101, parent_id=103, dim_id=102,
|
||||
is_isa=True, confidence=0.85, source="gutask",
|
||||
)
|
||||
await insert_urd_edge(pool, req)
|
||||
|
||||
assert 101 in cache.pending_conflicts
|
||||
block = build_recollection_block([101], confidence_floor=0.5, recency_days=90)
|
||||
assert block is not None
|
||||
assert "[usa?]" in block, (
|
||||
f"recollection must show [usa?] pending marker. Got:\n{block}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 5: Simulate resolution — move facts to correct dimensions
|
||||
# After nightly job:
|
||||
# michigan ISA state in dim:type (the ISA fact)
|
||||
# michigan ISPART usa in dim:geography (the ISPART fact, moved off degenerate dim)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_recollection_clean_after_simulated_resolution(self):
|
||||
# Remove the degenerate edge
|
||||
del cache.urd_by_concept_dim[(101, 102)]
|
||||
cache.urd_by_concept[101] = []
|
||||
|
||||
# Insert two correctly-dimensioned edges
|
||||
geo_edge = add_urd(
|
||||
concept_id=101, parent_id=102, dim_id=5, # geography dim
|
||||
is_isa=False, confidence=0.9, source="festinger",
|
||||
)
|
||||
type_edge = add_urd(
|
||||
concept_id=101, parent_id=103, dim_id=1, # type dim
|
||||
is_isa=True, confidence=0.9, source="festinger",
|
||||
)
|
||||
cache.pending_conflicts.discard(101)
|
||||
|
||||
edges = query_edges(101, confidence_floor=0.5, recency_days=90)
|
||||
assert len(edges) == 2
|
||||
|
||||
line = render_hit("michigan", edges, concept_id=101)
|
||||
assert "[geography] usa" in line
|
||||
assert "[type] state" in line
|
||||
assert "?" not in line, "no pending marker after resolution"
|
||||
|
||||
def test_full_recollection_block_after_resolution(self):
|
||||
# Simulate post-resolution state
|
||||
del cache.urd_by_concept_dim[(101, 102)]
|
||||
cache.urd_by_concept[101] = []
|
||||
add_urd(concept_id=101, parent_id=102, dim_id=5, is_isa=False, confidence=0.9, source="festinger")
|
||||
add_urd(concept_id=101, parent_id=103, dim_id=1, is_isa=True, confidence=0.9, source="festinger")
|
||||
cache.pending_conflicts.discard(101)
|
||||
|
||||
block = build_recollection_block([101], confidence_floor=0.5, recency_days=90)
|
||||
assert block is not None
|
||||
assert "<recollection>" in block
|
||||
assert "michigan:" in block
|
||||
assert "[geography] usa" in block
|
||||
assert "[type] state" in block
|
||||
# No pending markers
|
||||
assert "?" not in block
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for the tokeniser — compound token rule, punctuation stripping, length filter."""
|
||||
import pytest
|
||||
from festinger.tokenizer import tokenize, tokenize_all
|
||||
|
||||
|
||||
def test_simple_tokens():
|
||||
# "repo" is 4 chars — filtered by the ≥5 rule. Use a longer word.
|
||||
tokens = tokenize("gnommoweb is a repository")
|
||||
assert "gnommoweb" in tokens
|
||||
assert "repository" in tokens
|
||||
assert "repo" not in tokens # 4 chars — below threshold
|
||||
|
||||
|
||||
def test_compound_token_rule():
|
||||
tokens = tokenize("Glitch University runs on Docker")
|
||||
assert "glitch_university" in tokens
|
||||
assert "docker" in tokens
|
||||
# Individual parts should NOT appear as separate tokens
|
||||
assert "glitch" not in tokens
|
||||
assert "university" not in tokens
|
||||
|
||||
|
||||
def test_multi_word_compound():
|
||||
tokens = tokenize("New York City is a place")
|
||||
assert "new_york_city" in tokens
|
||||
|
||||
|
||||
def test_lowercase_breaks_compound_run():
|
||||
# "the" breaks the run — "Glitch University" still merges
|
||||
tokens = tokenize("the Glitch University system")
|
||||
assert "glitch_university" in tokens
|
||||
assert "system" in tokens
|
||||
assert "glitch" not in tokens
|
||||
|
||||
|
||||
def test_length_filter():
|
||||
# Tokens < 5 chars are dropped
|
||||
tokens = tokenize("cat dog bird eagle")
|
||||
assert "eagle" in tokens
|
||||
assert "bird" not in tokens
|
||||
assert "cat" not in tokens
|
||||
assert "dog" not in tokens
|
||||
|
||||
|
||||
def test_punctuation_stripped():
|
||||
# Trailing punctuation (period, colon) breaks the compound run.
|
||||
# "FastAPI." ends a run immediately; "Docker:" starts and ends a fresh run.
|
||||
tokens = tokenize("gnommoweb, FastAPI. Docker:")
|
||||
assert "gnommoweb" in tokens
|
||||
assert "fastapi" in tokens # from "FastAPI." — flushed as solo compound
|
||||
assert "docker" in tokens # from "Docker:" — flushed as solo compound
|
||||
# Must NOT merge across sentence boundaries
|
||||
assert "fastapi_docker" not in tokens
|
||||
|
||||
|
||||
def test_deduplication():
|
||||
tokens = tokenize("gnommoweb gnommoweb gnommoweb")
|
||||
assert tokens.count("gnommoweb") == 1
|
||||
|
||||
|
||||
def test_empty_string():
|
||||
assert tokenize("") == []
|
||||
|
||||
|
||||
def test_tokenize_all_no_length_filter():
|
||||
# tokenize_all keeps short tokens
|
||||
tokens = tokenize_all("is a part of")
|
||||
assert "is" in tokens
|
||||
assert "of" in tokens
|
||||
assert "part" in tokens
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
WordNet Release 3.0 This software and database is being provided to you, the LICENSEE, by Princeton University under the following license. By obtaining, using and/or copying this software and database, you agree that you have read, understood, and will comply with these terms and conditions.: Permission to use, copy, modify and distribute this software and database and its documentation for any purpose and without fee or royalty is hereby granted, provided that you agree to comply with the following copyright notice and statements, including the disclaimer, and that the same appear on ALL copies of the software, database and documentation, including modifications that you make for internal use or for distribution. WordNet 3.0 Copyright 2006 by Princeton University. All rights reserved. THIS SOFTWARE AND DATABASE IS PROVIDED "AS IS" AND PRINCETON UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PRINCETON UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES OF MERCHANT- ABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE LICENSED SOFTWARE, DATABASE OR DOCUMENTATION WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS. The name of Princeton University or Princeton may not be used in advertising or publicity pertaining to distribution of the software and/or database. Title to copyright in this software, database and any associated documentation shall at all times remain with Princeton University and LICENSEE agrees to preserve same.
|
||||
Reference in New Issue
Block a user