feat: Add Oghma RAG Proxy for SkyrimNet lore injection
RAG proxy that intercepts SkyrimNet LLM requests and enriches them with relevant Tamrielic lore from CHIM's Oghma Infinium database. Features: - FastAPI proxy compatible with OpenAI API - ChromaDB semantic search for lore retrieval - NPC profile extraction from SkyrimNet prompts - Google Sheets ingestion for CHIM's Oghma data - Kubernetes deployment manifests - Debug endpoint for RAG operation monitoring Collections ingested to iris-dev ChromaDB: - oghma_lore: 1951 entries (scholar knowledge) - oghma_basic: 1949 entries (commoner knowledge) - oghma_visual: 1151 entries (Omnisight perception) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
5
oghma-proxy/.gitignore
vendored
Normal file
5
oghma-proxy/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
config.local.yaml
|
||||||
|
.env
|
||||||
|
|
||||||
35
oghma-proxy/Dockerfile
Normal file
35
oghma-proxy/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Oghma RAG Proxy - Container Image
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
LABEL maintainer="dafit@eachpath.local"
|
||||||
|
LABEL description="RAG Proxy for SkyrimNet - Injects Tamrielic lore into NPC conversations"
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy project files
|
||||||
|
COPY pyproject.toml .
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY config.yaml .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -e .
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -u 1000 oghma
|
||||||
|
USER oghma
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8100
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8100/health || exit 1
|
||||||
|
|
||||||
|
# Run the proxy
|
||||||
|
CMD ["python", "-m", "uvicorn", "oghma_proxy.main:app", "--host", "0.0.0.0", "--port", "8100"]
|
||||||
54
oghma-proxy/README.md
Normal file
54
oghma-proxy/README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Oghma RAG Proxy
|
||||||
|
|
||||||
|
RAG (Retrieval-Augmented Generation) proxy for SkyrimNet that injects Tamrielic lore into NPC conversations based on their knowledge profile.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This proxy sits between SkyrimNet and your LLM inference endpoint (OpenRouter/vLLM), enriching prompts with relevant lore from CHIM's Oghma Infinium database.
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- Zero changes to SkyrimNet — just change the endpoint URL
|
||||||
|
- NPC-aware filtering — guards don't know mage secrets
|
||||||
|
- Two-tier knowledge — scholars get deep lore, commoners get basics
|
||||||
|
- ChromaDB-powered semantic search
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# Ingest Oghma lore into ChromaDB
|
||||||
|
python -m oghma_proxy.ingest --host iris-dev.eachpath.local --port 35000
|
||||||
|
|
||||||
|
# Run proxy
|
||||||
|
python -m oghma_proxy.main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy `config.yaml` to `config.local.yaml` and customize:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
upstream:
|
||||||
|
url: https://openrouter.ai/api/v1
|
||||||
|
api_key: ${OPENROUTER_API_KEY}
|
||||||
|
|
||||||
|
chromadb:
|
||||||
|
host: iris-dev.eachpath.local
|
||||||
|
port: 35000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kubernetes Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -k k8s/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
See [TECHNICAL-SPEC.md](TECHNICAL-SPEC.md) for full design documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Part of the [nimmerverse](https://github.com/dafit/nimmerverse) project.
|
||||||
497
oghma-proxy/TECHNICAL-SPEC.md
Normal file
497
oghma-proxy/TECHNICAL-SPEC.md
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
# Oghma RAG Proxy — Technical Specification
|
||||||
|
|
||||||
|
**Project:** SkyrimNet Lore Enhancement via RAG Proxy
|
||||||
|
**Status:** Design Phase
|
||||||
|
**Author:** Chrysalis + dafit
|
||||||
|
**Created:** 2026-03-30
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem Statement
|
||||||
|
|
||||||
|
SkyrimNet currently relies on:
|
||||||
|
1. LLM's baked-in Skyrim knowledge (incomplete, potentially wrong)
|
||||||
|
2. Dynamic memories (what the NPC witnessed)
|
||||||
|
|
||||||
|
**Missing:** Authoritative lore retrieval filtered by what each NPC *should* know.
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
- Knowledge bleedover (guard knows Telvanni secrets)
|
||||||
|
- Lore inaccuracies (mixing up timelines, factions)
|
||||||
|
- No grounding in canon
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Solution: Oghma RAG Proxy
|
||||||
|
|
||||||
|
A transparent proxy that sits between SkyrimNet and the LLM inference endpoint.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ OGHMA RAG PROXY │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ SkyrimNet │ │ Oghma Proxy │ │ OpenRouter │ │
|
||||||
|
│ │ SKSE Plugin │────────▶│ (FastAPI) │────────▶│ / vLLM │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ Port: N/A │ │ Port: 8100 │ │ Upstream │ │
|
||||||
|
│ └──────────────┘ └────────┬─────────┘ └──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ Query │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────────┐ │
|
||||||
|
│ │ iris-dev │ │
|
||||||
|
│ │ ChromaDB │ │
|
||||||
|
│ │ Port: 35000 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Collections: │ │
|
||||||
|
│ │ - oghma_lore │ │
|
||||||
|
│ │ - oghma_basic │ │
|
||||||
|
│ └──────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Core Principles
|
||||||
|
|
||||||
|
1. **Zero SkyrimNet Changes** — Only change the endpoint URL in config
|
||||||
|
2. **Transparent Passthrough** — Unknown requests forward unchanged
|
||||||
|
3. **NPC-Aware Filtering** — Lore filtered by extracted NPC profile
|
||||||
|
4. **Two-Tier Content** — Scholars get deep lore, commoners get basics
|
||||||
|
5. **Nimmerverse Native** — Runs on existing infrastructure (iris-dev)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architecture Components
|
||||||
|
|
||||||
|
### 4.1 Oghma Proxy Service
|
||||||
|
|
||||||
|
**Location:** VM on nimmerverse (could run on phoebe-dev or dedicated)
|
||||||
|
**Tech Stack:** Python 3.11+, FastAPI, httpx, chromadb-client
|
||||||
|
**Port:** 8100 (configurable)
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
- Intercept OpenRouter-compatible API requests
|
||||||
|
- Parse prompts to extract NPC context
|
||||||
|
- Query ChromaDB for relevant lore
|
||||||
|
- Inject lore into system prompt
|
||||||
|
- Forward to upstream LLM
|
||||||
|
- Stream response back to SkyrimNet
|
||||||
|
|
||||||
|
### 4.2 Oghma ChromaDB Collection
|
||||||
|
|
||||||
|
**Location:** iris-dev.eachpath.local:35000
|
||||||
|
**Collections:**
|
||||||
|
|
||||||
|
| Collection | Content | Use Case |
|
||||||
|
|------------|---------|----------|
|
||||||
|
| `oghma_lore` | Full `topic_desc` entries | Scholars, mages, priests |
|
||||||
|
| `oghma_basic` | Simplified `topic_desc_basic` | Commoners, guards, peasants |
|
||||||
|
|
||||||
|
**Metadata Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"topic": "Akatosh",
|
||||||
|
"category": "Figures/Gods",
|
||||||
|
"knowledge_classes": ["priest", "scholar", "dragon", "snowelf"],
|
||||||
|
"knowledge_classes_basic": ["nord", "imperial", "breton"],
|
||||||
|
"tags": ["divine", "time", "dragon-god"],
|
||||||
|
"source_sheet": "Figures/Gods"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 NPC Profile Extractor
|
||||||
|
|
||||||
|
Parses SkyrimNet prompts to extract:
|
||||||
|
- NPC name
|
||||||
|
- Race
|
||||||
|
- Profession/class (from bio or context)
|
||||||
|
- Factions
|
||||||
|
- Location (hold)
|
||||||
|
- Special traits
|
||||||
|
|
||||||
|
**Extraction Patterns:**
|
||||||
|
```python
|
||||||
|
# From character bio section
|
||||||
|
r"## (?P<name>\w+) Bio\n- Gender: (?P<gender>\w+)\n- Race: (?P<race>\w+)"
|
||||||
|
|
||||||
|
# From system context
|
||||||
|
r"You are (?P<name>[^,]+), a (?P<race>\w+) (?P<profession>\w+)"
|
||||||
|
|
||||||
|
# From faction mentions
|
||||||
|
r"member of (?:the )?(?P<faction>[\w\s]+)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Data Flow
|
||||||
|
|
||||||
|
### 5.1 Request Interception
|
||||||
|
|
||||||
|
```
|
||||||
|
1. SkyrimNet sends POST /chat/completions to proxy
|
||||||
|
2. Proxy extracts NPC profile from messages
|
||||||
|
3. Proxy extracts conversation context/topic
|
||||||
|
4. Proxy queries ChromaDB:
|
||||||
|
- Collection: oghma_lore or oghma_basic (based on NPC education)
|
||||||
|
- Filter: knowledge_classes intersects NPC's classes
|
||||||
|
- Query: conversation context (semantic search)
|
||||||
|
- Limit: 3-5 most relevant entries
|
||||||
|
5. Proxy injects lore block into system message
|
||||||
|
6. Proxy forwards enriched request to upstream
|
||||||
|
7. Proxy streams response back to SkyrimNet
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Lore Injection Format
|
||||||
|
|
||||||
|
Injected after character bio, before conversation:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Relevant Lore Knowledge
|
||||||
|
|
||||||
|
Based on your background as a Nord priest in Whiterun, you would know:
|
||||||
|
|
||||||
|
- **Talos**: [Condensed lore about Talos worship, appropriate to character]
|
||||||
|
- **Whiterun**: [Local knowledge about the hold]
|
||||||
|
- **Companions**: [If character has connection]
|
||||||
|
|
||||||
|
Remember: This is knowledge your character possesses. Reference it naturally in conversation, don't recite it.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. NPC Knowledge Classification
|
||||||
|
|
||||||
|
### 6.1 Profile → Knowledge Classes Mapping
|
||||||
|
|
||||||
|
```python
|
||||||
|
RACE_CLASSES = {
|
||||||
|
"Nord": ["nord"],
|
||||||
|
"Dunmer": ["darkelf", "dunmer"],
|
||||||
|
"Altmer": ["highelf", "altmer"],
|
||||||
|
"Bosmer": ["woodelf", "bosmer"],
|
||||||
|
"Argonian": ["argonian"],
|
||||||
|
"Khajiit": ["khajiit"],
|
||||||
|
"Breton": ["breton"],
|
||||||
|
"Redguard": ["redguard"],
|
||||||
|
"Orsimer": ["orc", "orsimer"],
|
||||||
|
"Imperial": ["imperial"],
|
||||||
|
}
|
||||||
|
|
||||||
|
PROFESSION_CLASSES = {
|
||||||
|
"priest": ["priest"],
|
||||||
|
"mage": ["mage", "scholar"],
|
||||||
|
"scholar": ["scholar"],
|
||||||
|
"blacksmith": ["blacksmith"],
|
||||||
|
"guard": ["guard", "warrior"],
|
||||||
|
"thief": ["thief"],
|
||||||
|
"merchant": ["merchant"],
|
||||||
|
"innkeeper": ["innkeeper"],
|
||||||
|
"hunter": ["hunter"],
|
||||||
|
"farmer": ["peasant"],
|
||||||
|
"noble": ["noble"],
|
||||||
|
"bard": ["bard"],
|
||||||
|
}
|
||||||
|
|
||||||
|
LOCATION_CLASSES = {
|
||||||
|
"Whiterun": ["whiterun"],
|
||||||
|
"Windhelm": ["eastmarch"],
|
||||||
|
"Solitude": ["haafingar"],
|
||||||
|
"Riften": ["rift"],
|
||||||
|
"Markarth": ["reach"],
|
||||||
|
"Morthal": ["hjaalmarch"],
|
||||||
|
"Dawnstar": ["pale"],
|
||||||
|
"Winterhold": ["winterhold"],
|
||||||
|
"Falkreath": ["falkreath"],
|
||||||
|
"Solstheim": ["solstheim"],
|
||||||
|
}
|
||||||
|
|
||||||
|
FACTION_CLASSES = {
|
||||||
|
"Companions": ["companions"],
|
||||||
|
"College of Winterhold": ["college", "mage"],
|
||||||
|
"Thieves Guild": ["thieves"],
|
||||||
|
"Dark Brotherhood": ["darkbrotherhood"],
|
||||||
|
"Stormcloaks": ["stormcloak"],
|
||||||
|
"Imperial Legion": ["imperial"],
|
||||||
|
"Thalmor": ["thalmor"],
|
||||||
|
"Dawnguard": ["dawnguard"],
|
||||||
|
"Volkihar": ["vampire", "volkihar"],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Education Level Detection
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_education_level(npc_profile: NPCProfile) -> str:
|
||||||
|
"""Determine if NPC gets full lore or basic summaries."""
|
||||||
|
|
||||||
|
educated_professions = {"mage", "scholar", "priest", "noble", "bard"}
|
||||||
|
educated_factions = {"College of Winterhold", "Thalmor"}
|
||||||
|
|
||||||
|
if npc_profile.profession in educated_professions:
|
||||||
|
return "scholar"
|
||||||
|
if any(f in educated_factions for f in npc_profile.factions):
|
||||||
|
return "scholar"
|
||||||
|
|
||||||
|
return "commoner"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API Specification
|
||||||
|
|
||||||
|
### 7.1 Proxy Endpoints
|
||||||
|
|
||||||
|
**POST /v1/chat/completions** (OpenRouter compatible)
|
||||||
|
- Intercepts, enriches, forwards
|
||||||
|
- Supports streaming
|
||||||
|
|
||||||
|
**POST /v1/completions** (Legacy)
|
||||||
|
- Same enrichment logic
|
||||||
|
|
||||||
|
**GET /health**
|
||||||
|
- Returns proxy + ChromaDB status
|
||||||
|
|
||||||
|
**GET /stats**
|
||||||
|
- Lore injection statistics
|
||||||
|
- Cache hit rates
|
||||||
|
- Average latency added
|
||||||
|
|
||||||
|
### 7.2 Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# oghma-proxy.yaml
|
||||||
|
proxy:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 8100
|
||||||
|
|
||||||
|
upstream:
|
||||||
|
# OpenRouter
|
||||||
|
url: https://openrouter.ai/api/v1
|
||||||
|
api_key: ${OPENROUTER_API_KEY}
|
||||||
|
# Or local vLLM
|
||||||
|
# url: http://localhost:8000/v1
|
||||||
|
|
||||||
|
chromadb:
|
||||||
|
host: iris-dev.eachpath.local
|
||||||
|
port: 35000
|
||||||
|
collection_lore: oghma_lore
|
||||||
|
collection_basic: oghma_basic
|
||||||
|
|
||||||
|
retrieval:
|
||||||
|
max_results: 5
|
||||||
|
min_score: 0.6
|
||||||
|
|
||||||
|
injection:
|
||||||
|
enabled: true
|
||||||
|
position: after_bio # after_bio | before_conversation | system_suffix
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: INFO
|
||||||
|
log_injections: true
|
||||||
|
log_to_phoebe: true # Log to nimmerverse decision table
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Deployment Architecture
|
||||||
|
|
||||||
|
### 8.1 Host Options
|
||||||
|
|
||||||
|
**Option A: Dedicated VM (Recommended)**
|
||||||
|
```
|
||||||
|
VM ID: 109
|
||||||
|
Hostname: oghma.eachpath.local
|
||||||
|
IP: 10.0.40.109
|
||||||
|
Resources: 2 vCPU, 4GB RAM
|
||||||
|
OS: Rocky Linux 10
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Co-locate on phoebe-dev**
|
||||||
|
```
|
||||||
|
Hostname: phoebe-dev.eachpath.local
|
||||||
|
Port: 8100 (alongside PostgreSQL 35432)
|
||||||
|
Pros: No new VM, shared resources
|
||||||
|
Cons: Resource contention
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Service Configuration
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/oghma-proxy.service
|
||||||
|
[Unit]
|
||||||
|
Description=Oghma RAG Proxy for SkyrimNet
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=chrysalis
|
||||||
|
WorkingDirectory=/opt/oghma-proxy
|
||||||
|
ExecStart=/opt/oghma-proxy/venv/bin/python -m uvicorn main:app --host 0.0.0.0 --port 8100
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=OPENROUTER_API_KEY=your-key-here
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 SkyrimNet Configuration Change
|
||||||
|
|
||||||
|
In `config/OpenRouter.yaml`:
|
||||||
|
```yaml
|
||||||
|
# Before
|
||||||
|
openrouter:
|
||||||
|
base_url: https://openrouter.ai/api/v1
|
||||||
|
|
||||||
|
# After
|
||||||
|
openrouter:
|
||||||
|
base_url: http://oghma.eachpath.local:8100/v1
|
||||||
|
# Or if running locally:
|
||||||
|
# base_url: http://localhost:8100/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Data Pipeline: Oghma Ingestion
|
||||||
|
|
||||||
|
### 9.1 Google Sheets → ChromaDB Pipeline
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ingest_oghma.py
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import chromadb
|
||||||
|
from chromadb.config import Settings
|
||||||
|
|
||||||
|
SHEET_ID = "1dcfctU-iOqprwy2BOc7___4Awteczgdlv8886KalPsQ"
|
||||||
|
SHEETS = [
|
||||||
|
("Figures/Gods", 0),
|
||||||
|
("Artifacts", 1),
|
||||||
|
("Locations - Whiterun", 2),
|
||||||
|
# ... etc
|
||||||
|
]
|
||||||
|
|
||||||
|
def ingest_sheet(sheet_name: str, gid: int, chroma_client):
|
||||||
|
url = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={gid}"
|
||||||
|
df = pd.read_csv(url)
|
||||||
|
|
||||||
|
collection_lore = chroma_client.get_or_create_collection("oghma_lore")
|
||||||
|
collection_basic = chroma_client.get_or_create_collection("oghma_basic")
|
||||||
|
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
topic = row['topic']
|
||||||
|
|
||||||
|
# Full lore for educated NPCs
|
||||||
|
if pd.notna(row.get('topic_desc')):
|
||||||
|
collection_lore.add(
|
||||||
|
documents=[row['topic_desc']],
|
||||||
|
ids=[f"{sheet_name}:{topic}"],
|
||||||
|
metadatas=[{
|
||||||
|
"topic": topic,
|
||||||
|
"category": sheet_name,
|
||||||
|
"knowledge_classes": row.get('knowledge_class', ''),
|
||||||
|
"tags": row.get('tags', ''),
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Basic lore for commoners
|
||||||
|
if pd.notna(row.get('topic_desc_basic')):
|
||||||
|
collection_basic.add(
|
||||||
|
documents=[row['topic_desc_basic']],
|
||||||
|
ids=[f"{sheet_name}:{topic}:basic"],
|
||||||
|
metadatas=[{
|
||||||
|
"topic": topic,
|
||||||
|
"category": sheet_name,
|
||||||
|
"knowledge_classes": row.get('knowledge_class_basic', ''),
|
||||||
|
"tags": row.get('tags', ''),
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Embedding Model
|
||||||
|
|
||||||
|
Use same embedding model as SkyrimNet memories for consistency:
|
||||||
|
- **Model:** `all-MiniLM-L6-v2` (384 dimensions)
|
||||||
|
- **Or:** Match whatever SkyrimNet uses in Memory.yaml
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Foundation (Week 1)
|
||||||
|
- [ ] Set up oghma-proxy repository
|
||||||
|
- [ ] Implement basic FastAPI proxy (passthrough mode)
|
||||||
|
- [ ] Test with SkyrimNet (verify transparent forwarding)
|
||||||
|
- [ ] Deploy on phoebe-dev for initial testing
|
||||||
|
|
||||||
|
### Phase 2: Oghma Ingestion (Week 1-2)
|
||||||
|
- [ ] Write Google Sheets ingestion script
|
||||||
|
- [ ] Ingest all Oghma sheets into iris-dev ChromaDB
|
||||||
|
- [ ] Verify embeddings and metadata
|
||||||
|
- [ ] Test semantic queries manually
|
||||||
|
|
||||||
|
### Phase 3: NPC Profile Extraction (Week 2)
|
||||||
|
- [ ] Implement prompt parser for NPC context
|
||||||
|
- [ ] Build knowledge class mapper
|
||||||
|
- [ ] Test with various NPC types
|
||||||
|
- [ ] Handle edge cases (unnamed NPCs, generic guards)
|
||||||
|
|
||||||
|
### Phase 4: RAG Integration (Week 2-3)
|
||||||
|
- [ ] Implement ChromaDB query logic
|
||||||
|
- [ ] Build lore injection formatter
|
||||||
|
- [ ] Add education-level routing (scholar vs commoner)
|
||||||
|
- [ ] Test end-to-end with SkyrimNet
|
||||||
|
|
||||||
|
### Phase 5: Polish & Deploy (Week 3)
|
||||||
|
- [ ] Add streaming support
|
||||||
|
- [ ] Implement caching (avoid re-querying same context)
|
||||||
|
- [ ] Add metrics/logging to phoebe
|
||||||
|
- [ ] Create dedicated VM or finalize deployment
|
||||||
|
- [ ] Write operational runbook
|
||||||
|
|
||||||
|
### Phase 6: Iteration (Ongoing)
|
||||||
|
- [ ] Tune retrieval parameters based on gameplay
|
||||||
|
- [ ] Add more knowledge sources beyond Oghma
|
||||||
|
- [ ] Consider contributing upstream to SkyrimNet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Success Metrics
|
||||||
|
|
||||||
|
| Metric | Target | How to Measure |
|
||||||
|
|--------|--------|----------------|
|
||||||
|
| Lore accuracy | NPCs reference correct lore | Manual spot-checks |
|
||||||
|
| Knowledge scoping | Guards don't know mage secrets | Test with various NPC types |
|
||||||
|
| Latency overhead | < 200ms added | Proxy metrics |
|
||||||
|
| Stability | 99.9% uptime | Service monitoring |
|
||||||
|
| User experience | Immersion improved | Subjective gameplay testing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
|
|------|--------|------------|
|
||||||
|
| Latency too high | Breaks dialogue flow | Cache aggressively, async prefetch |
|
||||||
|
| Wrong lore injected | Immersion broken | Strict knowledge filtering, fallback to no injection |
|
||||||
|
| ChromaDB down | No lore enrichment | Graceful degradation (passthrough mode) |
|
||||||
|
| Prompt parsing fails | NPC profile unknown | Default to basic/generic lore |
|
||||||
|
| Oghma data incomplete | Missing topics | Supplement with UESP scraping |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Future Enhancements
|
||||||
|
|
||||||
|
1. **Player profile tracking** — Remember what player has learned, NPCs can reference shared knowledge
|
||||||
|
2. **Gossip propagation** — Lore spreads through NPC network with degradation
|
||||||
|
3. **Dynamic lore updates** — Events in-game add to lore corpus
|
||||||
|
4. **Multi-source RAG** — Combine Oghma + UESP + custom worldbuilding
|
||||||
|
5. **Upstream contribution** — Propose RAG API to SkyrimNet author
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Version:** 1.0 | **Created:** 2026-03-30 | **Updated:** 2026-03-30
|
||||||
76
oghma-proxy/config.yaml
Normal file
76
oghma-proxy/config.yaml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Oghma RAG Proxy Configuration
|
||||||
|
# Copy to config.local.yaml and customize for your environment
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 8100
|
||||||
|
workers: 1
|
||||||
|
|
||||||
|
upstream:
|
||||||
|
# OpenRouter (cloud)
|
||||||
|
url: https://openrouter.ai/api/v1
|
||||||
|
api_key: ${OPENROUTER_API_KEY}
|
||||||
|
|
||||||
|
# Local vLLM alternative:
|
||||||
|
# url: http://localhost:8000/v1
|
||||||
|
# api_key: ""
|
||||||
|
|
||||||
|
timeout: 120 # seconds
|
||||||
|
stream_timeout: 300 # for streaming responses
|
||||||
|
|
||||||
|
chromadb:
|
||||||
|
host: iris-dev.eachpath.local
|
||||||
|
port: 35000
|
||||||
|
collection_lore: oghma_lore
|
||||||
|
collection_basic: oghma_basic
|
||||||
|
|
||||||
|
retrieval:
|
||||||
|
max_results: 5
|
||||||
|
min_score: 0.55
|
||||||
|
embedding_model: all-MiniLM-L6-v2 # Match SkyrimNet memory embeddings
|
||||||
|
|
||||||
|
injection:
|
||||||
|
enabled: true
|
||||||
|
position: after_bio # after_bio | before_conversation | system_suffix
|
||||||
|
|
||||||
|
# Injection template
|
||||||
|
template: |
|
||||||
|
## Relevant Lore Knowledge
|
||||||
|
|
||||||
|
Based on your background, you would know:
|
||||||
|
|
||||||
|
{% for entry in lore_entries %}
|
||||||
|
- **{{ entry.topic }}**: {{ entry.content }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
Remember: Reference this knowledge naturally in conversation when relevant.
|
||||||
|
|
||||||
|
npc_extraction:
|
||||||
|
# Regex patterns for extracting NPC info from prompts
|
||||||
|
patterns:
|
||||||
|
bio_header: '## (?P<name>[\w\s]+) Bio\n- Gender: (?P<gender>\w+)\n- Race: (?P<race>\w+)'
|
||||||
|
role_context: 'You are (?P<name>[^,]+), (?:a |an )?(?P<race>\w+)'
|
||||||
|
faction_member: 'member of (?:the )?(?P<faction>[\w\s]+)'
|
||||||
|
location_in: '(?:in|at|near) (?P<location>Whiterun|Windhelm|Solitude|Riften|Markarth|Morthal|Dawnstar|Winterhold|Falkreath)'
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: INFO
|
||||||
|
format: json # json | console
|
||||||
|
log_injections: true
|
||||||
|
|
||||||
|
# Log decisions to phoebe for analysis
|
||||||
|
phoebe:
|
||||||
|
enabled: true
|
||||||
|
host: phoebe-dev.eachpath.local
|
||||||
|
port: 35432
|
||||||
|
database: nimmerverse
|
||||||
|
table: oghma_proxy_decisions
|
||||||
|
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
ttl_seconds: 300 # Cache lore lookups for 5 minutes
|
||||||
|
max_size: 1000 # Max cached queries
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
enabled: true
|
||||||
|
endpoint: /metrics # Prometheus-compatible
|
||||||
50
oghma-proxy/k8s/configmap.yaml
Normal file
50
oghma-proxy/k8s/configmap.yaml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: oghma-proxy-config
|
||||||
|
namespace: nimmersky
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: oghma-proxy
|
||||||
|
app.kubernetes.io/part-of: nimmerverse
|
||||||
|
data:
|
||||||
|
config.yaml: |
|
||||||
|
proxy:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 8100
|
||||||
|
workers: 1
|
||||||
|
|
||||||
|
upstream:
|
||||||
|
# Configure via secret or environment variables
|
||||||
|
url: ${UPSTREAM_URL}
|
||||||
|
timeout: 120
|
||||||
|
stream_timeout: 300
|
||||||
|
|
||||||
|
chromadb:
|
||||||
|
# ChromaDB service - adjust based on your deployment
|
||||||
|
# Option 1: External (iris-dev VM)
|
||||||
|
host: iris-dev.eachpath.local
|
||||||
|
port: 35000
|
||||||
|
# Option 2: In-cluster ChromaDB
|
||||||
|
# host: chromadb.nimmersky.svc.cluster.local
|
||||||
|
# port: 8000
|
||||||
|
collection_lore: oghma_lore
|
||||||
|
collection_basic: oghma_basic
|
||||||
|
|
||||||
|
retrieval:
|
||||||
|
max_results: 5
|
||||||
|
min_score: 0.55
|
||||||
|
embedding_model: all-MiniLM-L6-v2
|
||||||
|
|
||||||
|
injection:
|
||||||
|
enabled: true
|
||||||
|
position: after_bio
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: INFO
|
||||||
|
format: json
|
||||||
|
log_injections: true
|
||||||
|
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
ttl_seconds: 300
|
||||||
|
max_size: 1000
|
||||||
108
oghma-proxy/k8s/deployment.yaml
Normal file
108
oghma-proxy/k8s/deployment.yaml
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: oghma-proxy
|
||||||
|
namespace: nimmersky
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: oghma-proxy
|
||||||
|
app.kubernetes.io/part-of: nimmerverse
|
||||||
|
app.kubernetes.io/component: inference-proxy
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: oghma-proxy
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: oghma-proxy
|
||||||
|
app.kubernetes.io/part-of: nimmerverse
|
||||||
|
annotations:
|
||||||
|
prometheus.io/scrape: "true"
|
||||||
|
prometheus.io/port: "8100"
|
||||||
|
prometheus.io/path: "/metrics"
|
||||||
|
spec:
|
||||||
|
serviceAccountName: oghma-proxy
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
fsGroup: 1000
|
||||||
|
|
||||||
|
containers:
|
||||||
|
- name: oghma-proxy
|
||||||
|
image: registry.eachpath.local/nimmerverse/oghma-proxy:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8100
|
||||||
|
protocol: TCP
|
||||||
|
|
||||||
|
env:
|
||||||
|
- name: OPENROUTER_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: oghma-proxy-secrets
|
||||||
|
key: OPENROUTER_API_KEY
|
||||||
|
- name: UPSTREAM_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: oghma-proxy-secrets
|
||||||
|
key: UPSTREAM_URL
|
||||||
|
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: /app/config.yaml
|
||||||
|
subPath: config.yaml
|
||||||
|
readOnly: true
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 30
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: oghma-proxy-config
|
||||||
|
|
||||||
|
# Prefer scheduling near inference workloads
|
||||||
|
affinity:
|
||||||
|
podAffinity:
|
||||||
|
preferredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- weight: 100
|
||||||
|
podAffinityTerm:
|
||||||
|
labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/component: inference
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: oghma-proxy
|
||||||
|
namespace: nimmersky
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: oghma-proxy
|
||||||
22
oghma-proxy/k8s/kustomization.yaml
Normal file
22
oghma-proxy/k8s/kustomization.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: nimmersky
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- configmap.yaml
|
||||||
|
- secret.yaml
|
||||||
|
- deployment.yaml
|
||||||
|
- service.yaml
|
||||||
|
|
||||||
|
commonLabels:
|
||||||
|
app.kubernetes.io/managed-by: kustomize
|
||||||
|
app.kubernetes.io/part-of: nimmerverse
|
||||||
|
|
||||||
|
images:
|
||||||
|
- name: registry.eachpath.local/nimmerverse/oghma-proxy
|
||||||
|
newTag: latest
|
||||||
|
|
||||||
|
# For production, use overlays:
|
||||||
|
# kustomize build k8s/overlays/production | kubectl apply -f -
|
||||||
7
oghma-proxy/k8s/namespace.yaml
Normal file
7
oghma-proxy/k8s/namespace.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: nimmersky
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: nimmerverse
|
||||||
|
app.kubernetes.io/component: gaming
|
||||||
33
oghma-proxy/k8s/secret.yaml
Normal file
33
oghma-proxy/k8s/secret.yaml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: oghma-proxy-secrets
|
||||||
|
namespace: nimmersky
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: oghma-proxy
|
||||||
|
app.kubernetes.io/part-of: nimmerverse
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
# Replace with actual values or use external secret management
|
||||||
|
OPENROUTER_API_KEY: "your-openrouter-api-key-here"
|
||||||
|
UPSTREAM_URL: "https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
|
---
|
||||||
|
# Alternative: Use ExternalSecret with Vault/Vaultwarden
|
||||||
|
# apiVersion: external-secrets.io/v1beta1
|
||||||
|
# kind: ExternalSecret
|
||||||
|
# metadata:
|
||||||
|
# name: oghma-proxy-secrets
|
||||||
|
# namespace: nimmersky
|
||||||
|
# spec:
|
||||||
|
# refreshInterval: 1h
|
||||||
|
# secretStoreRef:
|
||||||
|
# name: vault-backend
|
||||||
|
# kind: ClusterSecretStore
|
||||||
|
# target:
|
||||||
|
# name: oghma-proxy-secrets
|
||||||
|
# data:
|
||||||
|
# - secretKey: OPENROUTER_API_KEY
|
||||||
|
# remoteRef:
|
||||||
|
# key: nimmerverse/skyrimnet
|
||||||
|
# property: openrouter_api_key
|
||||||
39
oghma-proxy/k8s/service.yaml
Normal file
39
oghma-proxy/k8s/service.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: oghma-proxy
|
||||||
|
namespace: nimmersky
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: oghma-proxy
|
||||||
|
app.kubernetes.io/part-of: nimmerverse
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: oghma-proxy
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8100
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
|
||||||
|
---
|
||||||
|
# Optional: Expose externally via LoadBalancer or NodePort
|
||||||
|
# for SkyrimNet running on gaming PC outside the cluster
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: oghma-proxy-external
|
||||||
|
namespace: nimmersky
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: oghma-proxy
|
||||||
|
app.kubernetes.io/part-of: nimmerverse
|
||||||
|
spec:
|
||||||
|
type: NodePort
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: oghma-proxy
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8100
|
||||||
|
targetPort: http
|
||||||
|
nodePort: 30100 # Access via <node-ip>:30100
|
||||||
|
protocol: TCP
|
||||||
54
oghma-proxy/pyproject.toml
Normal file
54
oghma-proxy/pyproject.toml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "oghma-proxy"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "RAG Proxy for SkyrimNet - Injects Tamrielic lore into NPC conversations"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = "MIT"
|
||||||
|
authors = [
|
||||||
|
{ name = "dafit", email = "dafit@eachpath.local" },
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.109.0",
|
||||||
|
"uvicorn[standard]>=0.27.0",
|
||||||
|
"httpx>=0.26.0",
|
||||||
|
"chromadb>=0.4.22",
|
||||||
|
"pydantic>=2.5.0",
|
||||||
|
"pydantic-settings>=2.1.0",
|
||||||
|
"pyyaml>=6.0.1",
|
||||||
|
"structlog>=24.1.0",
|
||||||
|
"psycopg[binary]>=3.1.0", # For phoebe logging
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.4.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"httpx>=0.26.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
]
|
||||||
|
ingest = [
|
||||||
|
"pandas>=2.1.0",
|
||||||
|
"gspread>=5.12.0",
|
||||||
|
"sentence-transformers>=2.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
oghma-proxy = "oghma_proxy.main:main"
|
||||||
|
oghma-ingest = "oghma_proxy.ingest:main"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "N", "W", "UP"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
3
oghma-proxy/src/oghma_proxy/__init__.py
Normal file
3
oghma-proxy/src/oghma_proxy/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Oghma RAG Proxy - Lore enrichment for SkyrimNet."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
147
oghma-proxy/src/oghma_proxy/extractor.py
Normal file
147
oghma-proxy/src/oghma_proxy/extractor.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""NPC Profile Extractor - Parses SkyrimNet prompts to extract NPC context."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from .models import NPCProfile
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class NPCExtractor:
|
||||||
|
"""Extracts NPC profile information from SkyrimNet prompts."""
|
||||||
|
|
||||||
|
# Regex patterns for extraction
|
||||||
|
PATTERNS = {
|
||||||
|
# Character bio header
|
||||||
|
"bio_header": re.compile(
|
||||||
|
r"## (?P<name>[\w\s'-]+) Bio\s*\n"
|
||||||
|
r"- Gender: (?P<gender>\w+)\s*\n"
|
||||||
|
r"- Race: (?P<race>[\w\s]+)",
|
||||||
|
re.MULTILINE,
|
||||||
|
),
|
||||||
|
# Alternative role description
|
||||||
|
"role_intro": re.compile(
|
||||||
|
r"You are (?P<name>[^,\n]+),?\s*(?:a |an )?(?P<descriptor>[^.\n]+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
# Faction membership
|
||||||
|
"faction": re.compile(
|
||||||
|
r"(?:member of|belongs to|joined|part of) (?:the )?(?P<faction>[\w\s]+?)(?:\.|,|\n|$)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
# Location mentions
|
||||||
|
"location": re.compile(
|
||||||
|
r"(?:in|at|near|from) (?P<location>Whiterun|Windhelm|Solitude|Riften|"
|
||||||
|
r"Markarth|Morthal|Dawnstar|Winterhold|Falkreath|Riverwood|Rorikstead|"
|
||||||
|
r"Ivarstead|Solstheim|Raven Rock)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
# Profession/occupation
|
||||||
|
"occupation": re.compile(
|
||||||
|
r"(?:works as|profession:|occupation:|is a|as a) (?P<profession>[\w\s]+?)(?:\.|,|\n|$)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Known professions for fuzzy matching
|
||||||
|
KNOWN_PROFESSIONS = {
|
||||||
|
"priest", "priestess", "mage", "wizard", "scholar", "blacksmith",
|
||||||
|
"guard", "soldier", "warrior", "thief", "merchant", "innkeeper",
|
||||||
|
"hunter", "farmer", "peasant", "noble", "jarl", "bard", "alchemist",
|
||||||
|
"healer", "assassin", "spy", "courier", "carriage driver", "fisherman",
|
||||||
|
"miller", "brewer", "smith", "armorer", "fletcher", "jeweler",
|
||||||
|
}
|
||||||
|
|
||||||
|
def extract(self, messages: list[dict]) -> NPCProfile:
|
||||||
|
"""Extract NPC profile from chat messages."""
|
||||||
|
# Combine all message content for analysis
|
||||||
|
full_text = "\n".join(
|
||||||
|
msg.get("content", "") for msg in messages if msg.get("content")
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = NPCProfile()
|
||||||
|
|
||||||
|
# Try bio header first (most reliable)
|
||||||
|
if match := self.PATTERNS["bio_header"].search(full_text):
|
||||||
|
profile.name = match.group("name").strip()
|
||||||
|
profile.gender = match.group("gender").strip()
|
||||||
|
profile.race = match.group("race").strip()
|
||||||
|
logger.debug("Extracted from bio header", name=profile.name, race=profile.race)
|
||||||
|
|
||||||
|
# Fallback to role intro
|
||||||
|
elif match := self.PATTERNS["role_intro"].search(full_text):
|
||||||
|
profile.name = match.group("name").strip()
|
||||||
|
descriptor = match.group("descriptor")
|
||||||
|
# Try to parse race from descriptor
|
||||||
|
profile.race = self._extract_race_from_descriptor(descriptor)
|
||||||
|
logger.debug("Extracted from role intro", name=profile.name)
|
||||||
|
|
||||||
|
# Extract location
|
||||||
|
if match := self.PATTERNS["location"].search(full_text):
|
||||||
|
profile.location = match.group("location").strip()
|
||||||
|
|
||||||
|
# Extract factions
|
||||||
|
for match in self.PATTERNS["faction"].finditer(full_text):
|
||||||
|
faction = match.group("faction").strip()
|
||||||
|
if faction and faction not in profile.factions:
|
||||||
|
profile.factions.append(faction)
|
||||||
|
|
||||||
|
# Extract profession
|
||||||
|
if match := self.PATTERNS["occupation"].search(full_text):
|
||||||
|
profession = match.group("profession").strip().lower()
|
||||||
|
# Validate against known professions
|
||||||
|
for known in self.KNOWN_PROFESSIONS:
|
||||||
|
if known in profession:
|
||||||
|
profile.profession = known
|
||||||
|
break
|
||||||
|
|
||||||
|
# Compute knowledge classes
|
||||||
|
profile.compute_knowledge_classes()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Extracted NPC profile",
|
||||||
|
name=profile.name,
|
||||||
|
race=profile.race,
|
||||||
|
profession=profile.profession,
|
||||||
|
factions=profile.factions,
|
||||||
|
location=profile.location,
|
||||||
|
knowledge_classes=profile.knowledge_classes,
|
||||||
|
education_level=profile.education_level.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def _extract_race_from_descriptor(self, descriptor: str) -> str:
|
||||||
|
"""Try to extract race from a descriptor string."""
|
||||||
|
races = [
|
||||||
|
"Nord", "Dunmer", "Dark Elf", "Altmer", "High Elf",
|
||||||
|
"Bosmer", "Wood Elf", "Argonian", "Khajiit", "Breton",
|
||||||
|
"Redguard", "Orsimer", "Orc", "Imperial",
|
||||||
|
]
|
||||||
|
descriptor_lower = descriptor.lower()
|
||||||
|
for race in races:
|
||||||
|
if race.lower() in descriptor_lower:
|
||||||
|
# Normalize to single-word form
|
||||||
|
return race.replace(" ", "")
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def extract_conversation_context(self, messages: list[dict]) -> str:
|
||||||
|
"""Extract the current conversation topic for RAG query."""
|
||||||
|
# Get the last few user/assistant exchanges
|
||||||
|
recent_content = []
|
||||||
|
for msg in reversed(messages[-6:]):
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if content and msg.get("role") in ("user", "assistant"):
|
||||||
|
# Skip very long content (likely system prompts)
|
||||||
|
if len(content) < 500:
|
||||||
|
recent_content.append(content)
|
||||||
|
|
||||||
|
if not recent_content:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Combine recent conversation as the query context
|
||||||
|
return " ".join(reversed(recent_content[-3:]))
|
||||||
444
oghma-proxy/src/oghma_proxy/ingest.py
Normal file
444
oghma-proxy/src/oghma_proxy/ingest.py
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
"""Oghma Data Ingestion - Loads CHIM's Oghma lore into ChromaDB."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
import chromadb
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
from chromadb.config import Settings
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
# Google Sheet ID for CHIM's Oghma Infinium
|
||||||
|
OGHMA_SHEET_ID = "1dcfctU-iOqprwy2BOc7___4Awteczgdlv8886KalPsQ"
|
||||||
|
|
||||||
|
|
||||||
|
def discover_sheet_gids(sheet_id: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Discover actual gids for all sheets by parsing the HTML page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping sheet name to gid
|
||||||
|
"""
|
||||||
|
url = f"https://docs.google.com/spreadsheets/d/{sheet_id}/htmlview"
|
||||||
|
|
||||||
|
with httpx.Client(follow_redirects=True, timeout=30.0) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
html = response.text
|
||||||
|
|
||||||
|
sheet_gids = {}
|
||||||
|
|
||||||
|
# Pattern 1: items.push({name: "Sheet Name", ...gid: "12345"...})
|
||||||
|
# This is the format Google Sheets uses in the htmlview page
|
||||||
|
for match in re.finditer(
|
||||||
|
r'items\.push\(\{name:\s*"([^"]+)"[^}]*gid:\s*"(\d+)"',
|
||||||
|
html
|
||||||
|
):
|
||||||
|
name = match.group(1)
|
||||||
|
gid = match.group(2)
|
||||||
|
sheet_gids[name] = gid
|
||||||
|
|
||||||
|
# Pattern 2: Also check for gid in URL patterns as backup
|
||||||
|
# ...gid=12345", gid: "12345"...
|
||||||
|
if not sheet_gids:
|
||||||
|
for match in re.finditer(r'gid=(\d+)"[^}]*gid:\s*"(\d+)"', html):
|
||||||
|
gid = match.group(2)
|
||||||
|
# Try to find associated name nearby
|
||||||
|
# This is a fallback pattern
|
||||||
|
|
||||||
|
# Pattern 3: Look for the sheet tabs JSON structure
|
||||||
|
for match in re.finditer(
|
||||||
|
r'\{name:\s*"([^"]+)"[^}]*gid:\s*"(\d+)"[^}]*\}',
|
||||||
|
html
|
||||||
|
):
|
||||||
|
name = match.group(1)
|
||||||
|
gid = match.group(2)
|
||||||
|
if name not in sheet_gids:
|
||||||
|
sheet_gids[name] = gid
|
||||||
|
|
||||||
|
logger.info("Discovered sheets", count=len(sheet_gids), sheets=list(sheet_gids.keys())[:10])
|
||||||
|
return sheet_gids
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_sheet_csv(sheet_id: str, gid: str, sheet_name: str = "") -> str:
|
||||||
|
"""Fetch a Google Sheet as CSV."""
|
||||||
|
url = f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv&gid={gid}"
|
||||||
|
|
||||||
|
with httpx.Client(follow_redirects=True, timeout=60.0) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
if response.status_code == 400:
|
||||||
|
logger.warning("Sheet fetch failed with 400", sheet=sheet_name, gid=gid)
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
f"Failed to fetch sheet {sheet_name}",
|
||||||
|
request=response.request,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csv(csv_text: str) -> Iterator[dict]:
|
||||||
|
"""Parse CSV text into rows."""
|
||||||
|
import csv
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(csv_text))
|
||||||
|
for row in reader:
|
||||||
|
yield row
|
||||||
|
|
||||||
|
|
||||||
|
def categorize_sheet(sheet_name: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Determine category for a sheet and whether to process it.
|
||||||
|
|
||||||
|
Returns category name or None if sheet should be skipped.
|
||||||
|
"""
|
||||||
|
# Normalize escaped slashes from JSON
|
||||||
|
normalized_name = sheet_name.replace("\\/", "/").replace("\\", "")
|
||||||
|
|
||||||
|
# Sheets to process and their categories
|
||||||
|
sheet_categories = {
|
||||||
|
"Figures/Gods": "figures_gods",
|
||||||
|
"Artifacts": "artifacts",
|
||||||
|
"Armor and Weapons": "armor_weapons",
|
||||||
|
"Items": "items",
|
||||||
|
"Spells": "spells",
|
||||||
|
"Creatures": "creatures",
|
||||||
|
"Groups/Lore/Books": "groups_lore",
|
||||||
|
"Dynamic Oghma": "dynamic",
|
||||||
|
"Visual Descriptions": "visual",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check direct match
|
||||||
|
if normalized_name in sheet_categories:
|
||||||
|
return sheet_categories[normalized_name]
|
||||||
|
|
||||||
|
# Check location sheets - handles both "Locations - Whiterun" and "Locations (Whiterun)"
|
||||||
|
if normalized_name.startswith("Locations"):
|
||||||
|
# Try pattern: "Locations (Whiterun)"
|
||||||
|
match = re.match(r"Locations\s*[\(\-]\s*([^\)]+)\)?", normalized_name)
|
||||||
|
if match:
|
||||||
|
hold = match.group(1).strip().lower().replace(" ", "_")
|
||||||
|
return f"locations_{hold}"
|
||||||
|
|
||||||
|
# Skip meta/reference sheets
|
||||||
|
skip_sheets = ["Project Oghma", "Knowledge Classes Reference", "Vanilla NPCS", "Template"]
|
||||||
|
if any(skip in normalized_name for skip in skip_sheets):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_oghma(
|
||||||
|
chromadb_host: str = "iris-dev.eachpath.local",
|
||||||
|
chromadb_port: int = 35000,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Ingest all Oghma sheets into ChromaDB.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Statistics about ingestion
|
||||||
|
"""
|
||||||
|
stats = {
|
||||||
|
"sheets_processed": 0,
|
||||||
|
"sheets_skipped": 0,
|
||||||
|
"lore_entries": 0,
|
||||||
|
"basic_entries": 0,
|
||||||
|
"visual_entries": 0,
|
||||||
|
"errors": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Discover actual sheet gids
|
||||||
|
logger.info("Discovering sheet gids...")
|
||||||
|
try:
|
||||||
|
sheet_gids = discover_sheet_gids(OGHMA_SHEET_ID)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to discover sheets", error=str(e))
|
||||||
|
# Fallback to known gids (manually discovered)
|
||||||
|
sheet_gids = {
|
||||||
|
"Figures/Gods": "0",
|
||||||
|
# Add more as we discover them
|
||||||
|
}
|
||||||
|
|
||||||
|
if not sheet_gids:
|
||||||
|
logger.error("No sheets discovered!")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
client = chromadb.HttpClient(
|
||||||
|
host=chromadb_host,
|
||||||
|
port=chromadb_port,
|
||||||
|
settings=Settings(anonymized_telemetry=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get or create collections
|
||||||
|
collection_lore = client.get_or_create_collection(
|
||||||
|
name="oghma_lore",
|
||||||
|
metadata={"description": "Full Tamrielic lore for educated NPCs"},
|
||||||
|
)
|
||||||
|
collection_basic = client.get_or_create_collection(
|
||||||
|
name="oghma_basic",
|
||||||
|
metadata={"description": "Basic Tamrielic lore for commoners"},
|
||||||
|
)
|
||||||
|
collection_visual = client.get_or_create_collection(
|
||||||
|
name="oghma_visual",
|
||||||
|
metadata={"description": "Visual descriptions for Omnisight perception"},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Connected to ChromaDB", host=chromadb_host, port=chromadb_port)
|
||||||
|
else:
|
||||||
|
logger.info("DRY RUN - not connecting to ChromaDB")
|
||||||
|
collection_lore = None
|
||||||
|
collection_basic = None
|
||||||
|
collection_visual = None
|
||||||
|
|
||||||
|
for sheet_name, gid in sheet_gids.items():
|
||||||
|
category = categorize_sheet(sheet_name)
|
||||||
|
|
||||||
|
if category is None:
|
||||||
|
logger.debug("Skipping sheet", sheet=sheet_name)
|
||||||
|
stats["sheets_skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info("Processing sheet", sheet=sheet_name, gid=gid, category=category)
|
||||||
|
|
||||||
|
# Rate limit to avoid Google blocking
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
csv_text = fetch_sheet_csv(OGHMA_SHEET_ID, gid, sheet_name)
|
||||||
|
rows = list(parse_csv(csv_text))
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
logger.warning("Empty sheet", sheet=sheet_name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this sheet has the expected columns
|
||||||
|
sample_row = rows[0]
|
||||||
|
is_visual_sheet = category == "visual"
|
||||||
|
|
||||||
|
# Visual Descriptions has different schema: baseid, name, description
|
||||||
|
if is_visual_sheet:
|
||||||
|
if "name" not in sample_row or "description" not in sample_row:
|
||||||
|
logger.warning("Visual sheet missing expected columns", sheet=sheet_name, columns=list(sample_row.keys())[:5])
|
||||||
|
continue
|
||||||
|
elif "topic" not in sample_row:
|
||||||
|
logger.warning("Sheet missing 'topic' column", sheet=sheet_name, columns=list(sample_row.keys())[:5])
|
||||||
|
continue
|
||||||
|
|
||||||
|
lore_docs = []
|
||||||
|
lore_ids = []
|
||||||
|
lore_metadatas = []
|
||||||
|
|
||||||
|
basic_docs = []
|
||||||
|
basic_ids = []
|
||||||
|
basic_metadatas = []
|
||||||
|
|
||||||
|
# Visual descriptions - universal perception (Omnisight)
|
||||||
|
visual_docs = []
|
||||||
|
visual_ids = []
|
||||||
|
visual_metadatas = []
|
||||||
|
|
||||||
|
# Track seen IDs to handle duplicates
|
||||||
|
seen_ids = set()
|
||||||
|
duplicates_skipped = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
# Handle visual descriptions separately
|
||||||
|
if is_visual_sheet:
|
||||||
|
name = row.get("name", "").strip()
|
||||||
|
description = row.get("description", "").strip()
|
||||||
|
baseid = row.get("baseid", "").strip()
|
||||||
|
|
||||||
|
# Clean up Excel scientific notation for zero (0.00E+00)
|
||||||
|
if baseid and ("E+" in baseid or "E-" in baseid):
|
||||||
|
try:
|
||||||
|
if float(baseid) == 0:
|
||||||
|
baseid = ""
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if name and description:
|
||||||
|
# Use baseid:name for uniqueness, fall back to name only
|
||||||
|
doc_id = f"visual:{baseid}:{name}" if baseid else f"visual:{name}"
|
||||||
|
|
||||||
|
if doc_id in seen_ids:
|
||||||
|
duplicates_skipped += 1
|
||||||
|
continue
|
||||||
|
seen_ids.add(doc_id)
|
||||||
|
|
||||||
|
visual_docs.append(description)
|
||||||
|
visual_ids.append(doc_id)
|
||||||
|
visual_metadatas.append({
|
||||||
|
"name": name,
|
||||||
|
"baseid": baseid,
|
||||||
|
"category": "visual",
|
||||||
|
"sheet": sheet_name,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
topic = row.get("topic", "").strip()
|
||||||
|
if not topic:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Full lore entry
|
||||||
|
topic_desc = row.get("topic_desc", "").strip()
|
||||||
|
if topic_desc:
|
||||||
|
lore_id = f"{category}:{topic}"
|
||||||
|
|
||||||
|
if lore_id in seen_ids:
|
||||||
|
duplicates_skipped += 1
|
||||||
|
else:
|
||||||
|
seen_ids.add(lore_id)
|
||||||
|
knowledge_classes = row.get("knowledge_class", "").strip()
|
||||||
|
|
||||||
|
lore_docs.append(topic_desc)
|
||||||
|
lore_ids.append(lore_id)
|
||||||
|
lore_metadatas.append({
|
||||||
|
"topic": topic,
|
||||||
|
"category": category,
|
||||||
|
"sheet": sheet_name,
|
||||||
|
"knowledge_classes": knowledge_classes,
|
||||||
|
"tags": row.get("tags", "").strip(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Basic lore entry
|
||||||
|
topic_desc_basic = row.get("topic_desc_basic", "").strip()
|
||||||
|
if topic_desc_basic:
|
||||||
|
basic_id = f"{category}:{topic}:basic"
|
||||||
|
|
||||||
|
if basic_id in seen_ids:
|
||||||
|
duplicates_skipped += 1
|
||||||
|
else:
|
||||||
|
seen_ids.add(basic_id)
|
||||||
|
knowledge_classes_basic = row.get("knowledge_class_basic", "").strip()
|
||||||
|
|
||||||
|
basic_docs.append(topic_desc_basic)
|
||||||
|
basic_ids.append(basic_id)
|
||||||
|
basic_metadatas.append({
|
||||||
|
"topic": topic,
|
||||||
|
"category": category,
|
||||||
|
"sheet": sheet_name,
|
||||||
|
"knowledge_classes": knowledge_classes_basic,
|
||||||
|
"tags": row.get("tags", "").strip(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Batch insert to ChromaDB
|
||||||
|
if not dry_run:
|
||||||
|
if lore_docs:
|
||||||
|
collection_lore.upsert(
|
||||||
|
documents=lore_docs,
|
||||||
|
ids=lore_ids,
|
||||||
|
metadatas=lore_metadatas,
|
||||||
|
)
|
||||||
|
if basic_docs:
|
||||||
|
collection_basic.upsert(
|
||||||
|
documents=basic_docs,
|
||||||
|
ids=basic_ids,
|
||||||
|
metadatas=basic_metadatas,
|
||||||
|
)
|
||||||
|
if visual_docs:
|
||||||
|
collection_visual.upsert(
|
||||||
|
documents=visual_docs,
|
||||||
|
ids=visual_ids,
|
||||||
|
metadatas=visual_metadatas,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats["sheets_processed"] += 1
|
||||||
|
stats["lore_entries"] += len(lore_docs)
|
||||||
|
stats["basic_entries"] += len(basic_docs)
|
||||||
|
stats["visual_entries"] += len(visual_docs)
|
||||||
|
|
||||||
|
if duplicates_skipped > 0:
|
||||||
|
logger.debug("Duplicates skipped", sheet=sheet_name, count=duplicates_skipped)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Sheet processed",
|
||||||
|
sheet=sheet_name,
|
||||||
|
rows=len(rows),
|
||||||
|
lore_entries=len(lore_docs),
|
||||||
|
basic_entries=len(basic_docs),
|
||||||
|
visual_entries=len(visual_docs),
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error("HTTP error fetching sheet", sheet=sheet_name, status=e.response.status_code)
|
||||||
|
stats["errors"].append({"sheet": sheet_name, "error": f"HTTP {e.response.status_code}"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to process sheet", sheet=sheet_name, error=str(e))
|
||||||
|
stats["errors"].append({"sheet": sheet_name, "error": str(e)})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Ingestion complete",
|
||||||
|
sheets_processed=stats["sheets_processed"],
|
||||||
|
sheets_skipped=stats["sheets_skipped"],
|
||||||
|
lore_entries=stats["lore_entries"],
|
||||||
|
basic_entries=stats["basic_entries"],
|
||||||
|
visual_entries=stats["visual_entries"],
|
||||||
|
errors=len(stats["errors"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""CLI entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Ingest CHIM's Oghma Infinium lore into ChromaDB"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host",
|
||||||
|
default="iris-dev.eachpath.local",
|
||||||
|
help="ChromaDB host",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=35000,
|
||||||
|
help="ChromaDB port",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Fetch and parse sheets without writing to ChromaDB",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.dev.ConsoleRenderer(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats = ingest_oghma(
|
||||||
|
chromadb_host=args.host,
|
||||||
|
chromadb_port=args.port,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
if stats["errors"]:
|
||||||
|
logger.warning("Ingestion completed with errors", errors=stats["errors"])
|
||||||
|
# Don't exit 1 if we processed some sheets successfully
|
||||||
|
if stats["sheets_processed"] == 0:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Ingestion failed", error=str(e))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
153
oghma-proxy/src/oghma_proxy/injector.py
Normal file
153
oghma-proxy/src/oghma_proxy/injector.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""Lore Injector - Injects retrieved lore into SkyrimNet prompts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from .models import InjectionResult, LoreEntry, NPCProfile
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class LoreInjector:
|
||||||
|
"""Injects Oghma lore into SkyrimNet chat messages."""
|
||||||
|
|
||||||
|
DEFAULT_TEMPLATE = """
|
||||||
|
## Relevant Lore Knowledge
|
||||||
|
|
||||||
|
Based on your background as a {race} {profession} in {location}, you would know:
|
||||||
|
|
||||||
|
{lore_items}
|
||||||
|
|
||||||
|
Note: Reference this knowledge naturally when relevant to the conversation. Do not recite it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, template: str | None = None, position: str = "after_bio"):
|
||||||
|
"""
|
||||||
|
Initialize injector.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Jinja-style template for injection block
|
||||||
|
position: Where to inject - 'after_bio', 'before_conversation', 'system_suffix'
|
||||||
|
"""
|
||||||
|
self.template = template or self.DEFAULT_TEMPLATE
|
||||||
|
self.position = position
|
||||||
|
|
||||||
|
def inject(
|
||||||
|
self,
|
||||||
|
messages: list[dict],
|
||||||
|
npc_profile: NPCProfile,
|
||||||
|
lore_entries: list[LoreEntry],
|
||||||
|
query_time_ms: float,
|
||||||
|
) -> tuple[list[dict], InjectionResult]:
|
||||||
|
"""
|
||||||
|
Inject lore into chat messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: Original chat messages
|
||||||
|
npc_profile: Extracted NPC profile
|
||||||
|
lore_entries: Retrieved lore entries
|
||||||
|
query_time_ms: Time taken for retrieval
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (modified messages, injection result)
|
||||||
|
"""
|
||||||
|
if not lore_entries:
|
||||||
|
return messages, InjectionResult(
|
||||||
|
npc_profile=npc_profile,
|
||||||
|
lore_entries=[],
|
||||||
|
injection_text="",
|
||||||
|
query_time_ms=query_time_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build injection text
|
||||||
|
injection_text = self._build_injection_text(npc_profile, lore_entries)
|
||||||
|
|
||||||
|
# Clone messages to avoid modifying original
|
||||||
|
modified_messages = [dict(msg) for msg in messages]
|
||||||
|
|
||||||
|
# Find injection point
|
||||||
|
injected = False
|
||||||
|
for i, msg in enumerate(modified_messages):
|
||||||
|
if msg.get("role") == "system":
|
||||||
|
content = msg.get("content", "")
|
||||||
|
|
||||||
|
if self.position == "after_bio":
|
||||||
|
# Inject after character bio section
|
||||||
|
bio_markers = ["## Background", "## Personality", "## Speech Style"]
|
||||||
|
for marker in bio_markers:
|
||||||
|
if marker in content:
|
||||||
|
# Insert before this section
|
||||||
|
idx = content.index(marker)
|
||||||
|
modified_messages[i]["content"] = (
|
||||||
|
content[:idx] + injection_text + "\n\n" + content[idx:]
|
||||||
|
)
|
||||||
|
injected = True
|
||||||
|
break
|
||||||
|
|
||||||
|
elif self.position == "system_suffix":
|
||||||
|
# Append to end of system message
|
||||||
|
modified_messages[i]["content"] = content + "\n\n" + injection_text
|
||||||
|
injected = True
|
||||||
|
|
||||||
|
if injected:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback: prepend to first user message if no system message found
|
||||||
|
if not injected and self.position == "before_conversation":
|
||||||
|
for i, msg in enumerate(modified_messages):
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
content = msg.get("content", "")
|
||||||
|
modified_messages[i]["content"] = (
|
||||||
|
f"[Context for the NPC you're speaking with]\n{injection_text}\n\n"
|
||||||
|
f"[Player speaks]\n{content}"
|
||||||
|
)
|
||||||
|
injected = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if injected:
|
||||||
|
logger.info(
|
||||||
|
"Injected lore",
|
||||||
|
npc_name=npc_profile.name,
|
||||||
|
entries_count=len(lore_entries),
|
||||||
|
position=self.position,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Could not find injection point", position=self.position)
|
||||||
|
|
||||||
|
result = InjectionResult(
|
||||||
|
npc_profile=npc_profile,
|
||||||
|
lore_entries=lore_entries,
|
||||||
|
injection_text=injection_text if injected else "",
|
||||||
|
query_time_ms=query_time_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
return modified_messages, result
|
||||||
|
|
||||||
|
def _build_injection_text(
|
||||||
|
self,
|
||||||
|
npc_profile: NPCProfile,
|
||||||
|
lore_entries: list[LoreEntry],
|
||||||
|
) -> str:
|
||||||
|
"""Build the injection text block."""
|
||||||
|
# Build lore items list
|
||||||
|
lore_items = []
|
||||||
|
for entry in lore_entries:
|
||||||
|
# Truncate very long entries
|
||||||
|
content = entry.content
|
||||||
|
if len(content) > 300:
|
||||||
|
content = content[:297] + "..."
|
||||||
|
lore_items.append(f"- **{entry.topic}**: {content}")
|
||||||
|
|
||||||
|
lore_items_text = "\n".join(lore_items)
|
||||||
|
|
||||||
|
# Fill template
|
||||||
|
injection_text = self.template.format(
|
||||||
|
race=npc_profile.race or "person",
|
||||||
|
profession=npc_profile.profession or "citizen",
|
||||||
|
location=npc_profile.location or "Skyrim",
|
||||||
|
lore_items=lore_items_text,
|
||||||
|
name=npc_profile.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return injection_text.strip()
|
||||||
291
oghma-proxy/src/oghma_proxy/main.py
Normal file
291
oghma-proxy/src/oghma_proxy/main.py
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
"""Oghma RAG Proxy - Main FastAPI Application."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
import yaml
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from .extractor import NPCExtractor
|
||||||
|
from .injector import LoreInjector
|
||||||
|
from .models import ChatCompletionRequest
|
||||||
|
from .retriever import OghmaRetriever
|
||||||
|
|
||||||
|
# Configure structured logging
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.filter_by_level,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.JSONRenderer(),
|
||||||
|
],
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
context_class=dict,
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: str = "config.yaml") -> dict:
|
||||||
|
"""Load configuration from YAML file."""
|
||||||
|
# Try local config first, then default
|
||||||
|
for path in ["config.local.yaml", config_path]:
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path) as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
logger.info("Loaded config", path=path)
|
||||||
|
return config
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# Global instances
|
||||||
|
config = load_config()
|
||||||
|
extractor = NPCExtractor()
|
||||||
|
retriever: OghmaRetriever | None = None
|
||||||
|
injector: LoreInjector | None = None
|
||||||
|
http_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Application lifespan - setup and teardown."""
|
||||||
|
global retriever, injector, http_client
|
||||||
|
|
||||||
|
# Initialize components
|
||||||
|
chroma_config = config.get("chromadb", {})
|
||||||
|
retriever = OghmaRetriever(
|
||||||
|
host=chroma_config.get("host", "iris-dev.eachpath.local"),
|
||||||
|
port=chroma_config.get("port", 35000),
|
||||||
|
collection_lore=chroma_config.get("collection_lore", "oghma_lore"),
|
||||||
|
collection_basic=chroma_config.get("collection_basic", "oghma_basic"),
|
||||||
|
max_results=config.get("retrieval", {}).get("max_results", 5),
|
||||||
|
min_score=config.get("retrieval", {}).get("min_score", 0.55),
|
||||||
|
)
|
||||||
|
|
||||||
|
injection_config = config.get("injection", {})
|
||||||
|
injector = LoreInjector(
|
||||||
|
template=injection_config.get("template"),
|
||||||
|
position=injection_config.get("position", "after_bio"),
|
||||||
|
)
|
||||||
|
|
||||||
|
upstream_config = config.get("upstream", {})
|
||||||
|
http_client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(
|
||||||
|
connect=10.0,
|
||||||
|
read=upstream_config.get("timeout", 120.0),
|
||||||
|
write=30.0,
|
||||||
|
pool=10.0,
|
||||||
|
),
|
||||||
|
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Oghma RAG Proxy started",
|
||||||
|
upstream_url=upstream_config.get("url", ""),
|
||||||
|
chromadb_host=chroma_config.get("host"),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
if http_client:
|
||||||
|
await http_client.aclose()
|
||||||
|
logger.info("Oghma RAG Proxy stopped")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Oghma RAG Proxy",
|
||||||
|
description="RAG Proxy for SkyrimNet - Injects Tamrielic lore into NPC conversations",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
chromadb_healthy = retriever.health_check() if retriever else False
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy" if chromadb_healthy else "degraded",
|
||||||
|
"components": {
|
||||||
|
"proxy": "healthy",
|
||||||
|
"chromadb": "healthy" if chromadb_healthy else "unhealthy",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Debug: track recent RAG operations
|
||||||
|
_recent_rag_ops = []
|
||||||
|
|
||||||
|
@app.get("/stats")
|
||||||
|
async def get_stats():
|
||||||
|
"""Get proxy statistics."""
|
||||||
|
return {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"injection_enabled": config.get("injection", {}).get("enabled", True),
|
||||||
|
"upstream_url": config.get("upstream", {}).get("url", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/debug/rag")
|
||||||
|
async def debug_rag():
|
||||||
|
"""Debug endpoint to see recent RAG operations."""
|
||||||
|
return {"recent_operations": _recent_rag_ops[-20:]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/chat/completions")
|
||||||
|
async def chat_completions(request: Request):
|
||||||
|
"""
|
||||||
|
Proxy chat completions with RAG enrichment.
|
||||||
|
|
||||||
|
This endpoint intercepts OpenRouter-compatible requests,
|
||||||
|
enriches them with relevant Tamrielic lore, and forwards
|
||||||
|
to the upstream LLM.
|
||||||
|
"""
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
|
||||||
|
# Parse request body
|
||||||
|
body = await request.json()
|
||||||
|
messages = body.get("messages", [])
|
||||||
|
stream = body.get("stream", False)
|
||||||
|
|
||||||
|
# Extract NPC profile from messages
|
||||||
|
npc_profile = extractor.extract(messages)
|
||||||
|
|
||||||
|
# Get conversation context for RAG query
|
||||||
|
context = extractor.extract_conversation_context(messages)
|
||||||
|
|
||||||
|
# Retrieve relevant lore
|
||||||
|
lore_entries = []
|
||||||
|
query_time_ms = 0.0
|
||||||
|
if (
|
||||||
|
retriever
|
||||||
|
and injector
|
||||||
|
and config.get("injection", {}).get("enabled", True)
|
||||||
|
and context
|
||||||
|
):
|
||||||
|
lore_entries, query_time_ms = retriever.retrieve(context, npc_profile)
|
||||||
|
|
||||||
|
# Inject lore into messages
|
||||||
|
if lore_entries:
|
||||||
|
messages, injection_result = injector.inject(
|
||||||
|
messages,
|
||||||
|
npc_profile,
|
||||||
|
lore_entries,
|
||||||
|
query_time_ms,
|
||||||
|
)
|
||||||
|
body["messages"] = messages
|
||||||
|
|
||||||
|
# Track for debug endpoint
|
||||||
|
_recent_rag_ops.append({
|
||||||
|
"npc": npc_profile.name,
|
||||||
|
"race": npc_profile.race,
|
||||||
|
"query": context[:100] if context else "",
|
||||||
|
"lore_found": len(lore_entries),
|
||||||
|
"topics": [e.topic for e in lore_entries[:3]],
|
||||||
|
"time_ms": round(query_time_ms, 2),
|
||||||
|
})
|
||||||
|
if len(_recent_rag_ops) > 50:
|
||||||
|
_recent_rag_ops.pop(0)
|
||||||
|
|
||||||
|
# Forward to upstream
|
||||||
|
upstream_config = config.get("upstream", {})
|
||||||
|
upstream_url = upstream_config.get("url", "https://openrouter.ai/api/v1")
|
||||||
|
api_key = upstream_config.get("api_key", os.environ.get("OPENROUTER_API_KEY", ""))
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copy relevant headers from original request
|
||||||
|
for header in ["HTTP-Referer", "X-Title"]:
|
||||||
|
if value := request.headers.get(header):
|
||||||
|
headers[header] = value
|
||||||
|
|
||||||
|
try:
|
||||||
|
if stream:
|
||||||
|
# Streaming response
|
||||||
|
return StreamingResponse(
|
||||||
|
stream_upstream(f"{upstream_url}/chat/completions", headers, body),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Regular response
|
||||||
|
response = await http_client.post(
|
||||||
|
f"{upstream_url}/chat/completions",
|
||||||
|
json=body,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
total_time = (time.perf_counter() - start_time) * 1000
|
||||||
|
logger.info(
|
||||||
|
"Request completed",
|
||||||
|
npc_name=npc_profile.name,
|
||||||
|
lore_entries=len(lore_entries),
|
||||||
|
rag_time_ms=round(query_time_ms, 2),
|
||||||
|
total_time_ms=round(total_time, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error("Upstream request failed", error=str(e))
|
||||||
|
raise HTTPException(status_code=502, detail=f"Upstream error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_upstream(url: str, headers: dict, body: dict):
|
||||||
|
"""Stream response from upstream."""
|
||||||
|
async with http_client.stream("POST", url, json=body, headers=headers) as response:
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/completions")
|
||||||
|
async def completions(request: Request):
|
||||||
|
"""Legacy completions endpoint - passthrough."""
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
upstream_config = config.get("upstream", {})
|
||||||
|
upstream_url = upstream_config.get("url", "https://openrouter.ai/api/v1")
|
||||||
|
api_key = upstream_config.get("api_key", os.environ.get("OPENROUTER_API_KEY", ""))
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await http_client.post(
|
||||||
|
f"{upstream_url}/completions",
|
||||||
|
json=body,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run the proxy server."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
proxy_config = config.get("proxy", {})
|
||||||
|
uvicorn.run(
|
||||||
|
"oghma_proxy.main:app",
|
||||||
|
host=proxy_config.get("host", "0.0.0.0"),
|
||||||
|
port=proxy_config.get("port", 8100),
|
||||||
|
workers=proxy_config.get("workers", 1),
|
||||||
|
log_level="info",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
169
oghma-proxy/src/oghma_proxy/models.py
Normal file
169
oghma-proxy/src/oghma_proxy/models.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""Data models for Oghma RAG Proxy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class EducationLevel(str, Enum):
|
||||||
|
"""NPC education level determines lore depth."""
|
||||||
|
|
||||||
|
SCHOLAR = "scholar" # Full lore access
|
||||||
|
COMMONER = "commoner" # Basic summaries only
|
||||||
|
|
||||||
|
|
||||||
|
class NPCProfile(BaseModel):
|
||||||
|
"""Extracted NPC profile from SkyrimNet prompts."""
|
||||||
|
|
||||||
|
name: str = "Unknown"
|
||||||
|
race: str = "Unknown"
|
||||||
|
gender: str = "Unknown"
|
||||||
|
profession: str | None = None
|
||||||
|
factions: list[str] = Field(default_factory=list)
|
||||||
|
location: str | None = None
|
||||||
|
traits: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Computed
|
||||||
|
knowledge_classes: list[str] = Field(default_factory=list)
|
||||||
|
education_level: EducationLevel = EducationLevel.COMMONER
|
||||||
|
|
||||||
|
def compute_knowledge_classes(self) -> None:
|
||||||
|
"""Compute knowledge classes from profile attributes."""
|
||||||
|
classes = set()
|
||||||
|
|
||||||
|
# Race-based knowledge
|
||||||
|
race_map = {
|
||||||
|
"nord": ["nord"],
|
||||||
|
"dunmer": ["darkelf", "dunmer"],
|
||||||
|
"altmer": ["highelf", "altmer"],
|
||||||
|
"bosmer": ["woodelf", "bosmer"],
|
||||||
|
"argonian": ["argonian"],
|
||||||
|
"khajiit": ["khajiit"],
|
||||||
|
"breton": ["breton"],
|
||||||
|
"redguard": ["redguard"],
|
||||||
|
"orsimer": ["orc", "orsimer"],
|
||||||
|
"orc": ["orc", "orsimer"],
|
||||||
|
"imperial": ["imperial"],
|
||||||
|
}
|
||||||
|
race_lower = self.race.lower()
|
||||||
|
if race_lower in race_map:
|
||||||
|
classes.update(race_map[race_lower])
|
||||||
|
|
||||||
|
# Profession-based knowledge
|
||||||
|
profession_map = {
|
||||||
|
"priest": ["priest"],
|
||||||
|
"mage": ["mage", "scholar"],
|
||||||
|
"wizard": ["mage", "scholar"],
|
||||||
|
"scholar": ["scholar"],
|
||||||
|
"blacksmith": ["blacksmith"],
|
||||||
|
"guard": ["guard", "warrior"],
|
||||||
|
"soldier": ["warrior", "guard"],
|
||||||
|
"warrior": ["warrior"],
|
||||||
|
"thief": ["thief"],
|
||||||
|
"merchant": ["merchant"],
|
||||||
|
"innkeeper": ["innkeeper"],
|
||||||
|
"hunter": ["hunter"],
|
||||||
|
"farmer": ["peasant"],
|
||||||
|
"peasant": ["peasant"],
|
||||||
|
"noble": ["noble"],
|
||||||
|
"jarl": ["noble"],
|
||||||
|
"bard": ["bard"],
|
||||||
|
"alchemist": ["alchemist"],
|
||||||
|
}
|
||||||
|
if self.profession:
|
||||||
|
prof_lower = self.profession.lower()
|
||||||
|
if prof_lower in profession_map:
|
||||||
|
classes.update(profession_map[prof_lower])
|
||||||
|
|
||||||
|
# Location-based knowledge
|
||||||
|
location_map = {
|
||||||
|
"whiterun": ["whiterun"],
|
||||||
|
"windhelm": ["eastmarch"],
|
||||||
|
"solitude": ["haafingar"],
|
||||||
|
"riften": ["rift"],
|
||||||
|
"markarth": ["reach"],
|
||||||
|
"morthal": ["hjaalmarch"],
|
||||||
|
"dawnstar": ["pale"],
|
||||||
|
"winterhold": ["winterhold"],
|
||||||
|
"falkreath": ["falkreath"],
|
||||||
|
"solstheim": ["solstheim"],
|
||||||
|
}
|
||||||
|
if self.location:
|
||||||
|
loc_lower = self.location.lower()
|
||||||
|
if loc_lower in location_map:
|
||||||
|
classes.update(location_map[loc_lower])
|
||||||
|
|
||||||
|
# Faction-based knowledge
|
||||||
|
faction_map = {
|
||||||
|
"companions": ["companions"],
|
||||||
|
"college of winterhold": ["college", "mage"],
|
||||||
|
"college": ["college", "mage"],
|
||||||
|
"thieves guild": ["thieves"],
|
||||||
|
"dark brotherhood": ["darkbrotherhood"],
|
||||||
|
"stormcloaks": ["stormcloak"],
|
||||||
|
"stormcloak": ["stormcloak"],
|
||||||
|
"imperial legion": ["imperial"],
|
||||||
|
"legion": ["imperial"],
|
||||||
|
"thalmor": ["thalmor"],
|
||||||
|
"dawnguard": ["dawnguard"],
|
||||||
|
"volkihar": ["vampire", "volkihar"],
|
||||||
|
}
|
||||||
|
for faction in self.factions:
|
||||||
|
faction_lower = faction.lower()
|
||||||
|
if faction_lower in faction_map:
|
||||||
|
classes.update(faction_map[faction_lower])
|
||||||
|
|
||||||
|
self.knowledge_classes = list(classes)
|
||||||
|
|
||||||
|
# Determine education level
|
||||||
|
educated_professions = {"mage", "wizard", "scholar", "priest", "noble", "bard"}
|
||||||
|
educated_factions = {"college of winterhold", "thalmor", "college"}
|
||||||
|
|
||||||
|
if self.profession and self.profession.lower() in educated_professions:
|
||||||
|
self.education_level = EducationLevel.SCHOLAR
|
||||||
|
elif any(f.lower() in educated_factions for f in self.factions):
|
||||||
|
self.education_level = EducationLevel.SCHOLAR
|
||||||
|
else:
|
||||||
|
self.education_level = EducationLevel.COMMONER
|
||||||
|
|
||||||
|
|
||||||
|
class LoreEntry(BaseModel):
|
||||||
|
"""A retrieved lore entry from Oghma."""
|
||||||
|
|
||||||
|
topic: str
|
||||||
|
content: str
|
||||||
|
category: str
|
||||||
|
score: float
|
||||||
|
knowledge_classes: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
"""OpenRouter-compatible chat message."""
|
||||||
|
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionRequest(BaseModel):
|
||||||
|
"""OpenRouter-compatible chat completion request."""
|
||||||
|
|
||||||
|
model: str
|
||||||
|
messages: list[ChatMessage]
|
||||||
|
temperature: float | None = None
|
||||||
|
max_tokens: int | None = None
|
||||||
|
stream: bool = False
|
||||||
|
# Allow additional fields to pass through
|
||||||
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
|
||||||
|
class InjectionResult(BaseModel):
|
||||||
|
"""Result of lore injection."""
|
||||||
|
|
||||||
|
npc_profile: NPCProfile
|
||||||
|
lore_entries: list[LoreEntry]
|
||||||
|
injection_text: str
|
||||||
|
query_time_ms: float
|
||||||
173
oghma-proxy/src/oghma_proxy/retriever.py
Normal file
173
oghma-proxy/src/oghma_proxy/retriever.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""Oghma Lore Retriever - Queries ChromaDB for relevant Tamrielic lore."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import chromadb
|
||||||
|
import structlog
|
||||||
|
from chromadb.config import Settings
|
||||||
|
|
||||||
|
from .models import EducationLevel, LoreEntry, NPCProfile
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from chromadb import Collection
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class OghmaRetriever:
|
||||||
|
"""Retrieves relevant lore from Oghma ChromaDB collections."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str = "iris-dev.eachpath.local",
|
||||||
|
port: int = 35000,
|
||||||
|
collection_lore: str = "oghma_lore",
|
||||||
|
collection_basic: str = "oghma_basic",
|
||||||
|
max_results: int = 5,
|
||||||
|
min_score: float = 0.55,
|
||||||
|
):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.collection_lore_name = collection_lore
|
||||||
|
self.collection_basic_name = collection_basic
|
||||||
|
self.max_results = max_results
|
||||||
|
self.min_score = min_score
|
||||||
|
|
||||||
|
self._client: chromadb.HttpClient | None = None
|
||||||
|
self._collection_lore: Collection | None = None
|
||||||
|
self._collection_basic: Collection | None = None
|
||||||
|
|
||||||
|
def _get_client(self) -> chromadb.HttpClient:
|
||||||
|
"""Get or create ChromaDB client."""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = chromadb.HttpClient(
|
||||||
|
host=self.host,
|
||||||
|
port=self.port,
|
||||||
|
settings=Settings(anonymized_telemetry=False),
|
||||||
|
)
|
||||||
|
logger.info("Connected to ChromaDB", host=self.host, port=self.port)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def _get_collection(self, education_level: EducationLevel) -> Collection:
|
||||||
|
"""Get the appropriate collection based on education level."""
|
||||||
|
client = self._get_client()
|
||||||
|
|
||||||
|
if education_level == EducationLevel.SCHOLAR:
|
||||||
|
if self._collection_lore is None:
|
||||||
|
self._collection_lore = client.get_collection(self.collection_lore_name)
|
||||||
|
return self._collection_lore
|
||||||
|
else:
|
||||||
|
if self._collection_basic is None:
|
||||||
|
self._collection_basic = client.get_collection(self.collection_basic_name)
|
||||||
|
return self._collection_basic
|
||||||
|
|
||||||
|
def retrieve(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
npc_profile: NPCProfile,
|
||||||
|
) -> tuple[list[LoreEntry], float]:
|
||||||
|
"""
|
||||||
|
Retrieve relevant lore entries for an NPC.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Conversation context to search for
|
||||||
|
npc_profile: NPC profile for knowledge filtering
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (lore entries, query time in ms)
|
||||||
|
"""
|
||||||
|
if not query.strip():
|
||||||
|
return [], 0.0
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
|
||||||
|
try:
|
||||||
|
collection = self._get_collection(npc_profile.education_level)
|
||||||
|
|
||||||
|
# Build metadata filter for knowledge classes
|
||||||
|
# NOTE: Currently disabled because CHIM's Oghma data doesn't have
|
||||||
|
# knowledge_class populated consistently. Enable when data is enriched.
|
||||||
|
where_filter = None
|
||||||
|
# TODO: Re-enable when knowledge_class data is available
|
||||||
|
# if npc_profile.knowledge_classes:
|
||||||
|
# if len(npc_profile.knowledge_classes) == 1:
|
||||||
|
# where_filter = {"knowledge_classes": {"$contains": npc_profile.knowledge_classes[0]}}
|
||||||
|
# else:
|
||||||
|
# where_filter = {
|
||||||
|
# "$or": [
|
||||||
|
# {"knowledge_classes": {"$contains": kc}}
|
||||||
|
# for kc in npc_profile.knowledge_classes
|
||||||
|
# ]
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Query ChromaDB
|
||||||
|
results = collection.query(
|
||||||
|
query_texts=[query],
|
||||||
|
n_results=self.max_results,
|
||||||
|
where=where_filter,
|
||||||
|
include=["documents", "metadatas", "distances"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse results
|
||||||
|
entries = []
|
||||||
|
if results and results["documents"] and results["documents"][0]:
|
||||||
|
for i, doc in enumerate(results["documents"][0]):
|
||||||
|
metadata = results["metadatas"][0][i] if results["metadatas"] else {}
|
||||||
|
distance = results["distances"][0][i] if results["distances"] else 1.0
|
||||||
|
|
||||||
|
# Convert distance to similarity score (ChromaDB uses L2 distance)
|
||||||
|
# Lower distance = higher similarity
|
||||||
|
score = 1.0 / (1.0 + distance)
|
||||||
|
|
||||||
|
if score >= self.min_score:
|
||||||
|
entries.append(
|
||||||
|
LoreEntry(
|
||||||
|
topic=metadata.get("topic", "Unknown"),
|
||||||
|
content=doc,
|
||||||
|
category=metadata.get("category", "Unknown"),
|
||||||
|
score=score,
|
||||||
|
knowledge_classes=metadata.get("knowledge_classes", "").split(","),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
query_time = (time.perf_counter() - start_time) * 1000
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Retrieved lore entries",
|
||||||
|
query_preview=query[:100],
|
||||||
|
npc_name=npc_profile.name,
|
||||||
|
education=npc_profile.education_level.value,
|
||||||
|
entries_found=len(entries),
|
||||||
|
query_time_ms=round(query_time, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
return entries, query_time
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to retrieve lore", error=str(e))
|
||||||
|
query_time = (time.perf_counter() - start_time) * 1000
|
||||||
|
return [], query_time
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
"""Check if ChromaDB is reachable."""
|
||||||
|
try:
|
||||||
|
client = self._get_client()
|
||||||
|
client.heartbeat()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("ChromaDB health check failed", error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Cached retriever instance
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_retriever(
|
||||||
|
host: str = "iris-dev.eachpath.local",
|
||||||
|
port: int = 35000,
|
||||||
|
) -> OghmaRetriever:
|
||||||
|
"""Get cached retriever instance."""
|
||||||
|
return OghmaRetriever(host=host, port=port)
|
||||||
7
oghma-proxy/usage.txt
Normal file
7
oghma-proxy/usage.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
cd /home/dafit/nimmerverse/nimmersky/oghma-proxy
|
||||||
|
python -m oghma_proxy.main
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- http://localhost:8100/health - Health check
|
||||||
|
- http://localhost:8100/debug/rag - See recent RAG operations
|
||||||
|
- http://localhost:8100/v1/chat/completions - The proxy endpoint (point SkyrimNet here)
|
||||||
Reference in New Issue
Block a user