Skip to content

Commit 80681f1

Browse files
committed
Merge agent-web-integration: Ask-the-Crypt agent in the web app
In-app RAG agent (FastAPI + Next.js 'Ask the Crypt' panel, cited places pinned on the map), model-agnostic (Claude or free local Ollama), agent-result UI polish, a public/-assets Docker fix, and refreshed demo media.
2 parents 1995ebd + 7b6f4cf commit 80681f1

18 files changed

Lines changed: 452 additions & 51 deletions

File tree

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ country, ranked by visual similarity, distance, and lore.
1414

1515
<br />
1616

17-
<img src="docs/images/demo.gif" alt="Crypt demo: drop a photo, get ranked haunted places on the map" width="840" />
17+
<img src="docs/images/demo.gif" alt="Crypt demo: ask a question and get cited haunted places pinned on the map" width="840" />
1818

1919
</div>
2020

@@ -60,15 +60,15 @@ Rust**. The pieces:
6060

6161
## Screenshots
6262

63-
<img src="docs/images/app-search.png" alt="Visual search results" width="100%" />
63+
<img src="docs/images/app-search.png" alt="Ask the Crypt: cited places in the sidebar, pinned on the map" width="100%" />
6464

65-
Drop a photo and Crypt returns ranked matches: numbered pins on the map and
66-
scored results in the sidebar.
65+
Ask the Crypt a question and the agent answers with cited places: listed in the
66+
sidebar and pinned on the map (here, haunted cemeteries across Illinois).
6767

6868
| | |
6969
|:--|:--|
70-
| <img src="docs/images/app-detail.png" alt="Location detail panel" /> | <img src="docs/images/app-map.png" alt="Full-bleed haunted map" /> |
71-
| Every place opens a detail card with a category illustration, the recorded haunting, and links to dig deeper. | Collapse the panel for a map |
70+
| <img src="docs/images/app-detail.png" alt="Location detail panel" /> | <img src="docs/images/app-map.png" alt="The Ask box beside the full haunted map" /> |
71+
| Every place opens a detail card with a category illustration, the recorded haunting, and links to dig deeper. | The Ask box and visual search sit beside the full haunted-places map. |
7272

7373
## Highlights
7474

agent/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ retrieval stack with a tool-using planner and a faithfulness eval harness, so
66
every answer is grounded in retrieved evidence and the whole pipeline is
77
measured, not vibes.
88

9+
<img src="../docs/images/demo.gif" alt="Ask the Crypt: a question returns cited places pinned on the map" width="840" />
10+
911
## What it does
1012

1113
```

agent/crypt_agent/agent.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@
2828
2929
Keep answers tight: a few sentences or a short list, each item cited."""
3030

31+
# Used for the local-model (Ollama) path, which retrieves first then answers.
32+
RAG_SYSTEM = (
33+
"You are Crypt, answering questions about haunted and abandoned places "
34+
"strictly from the evidence given to you, citing each place's id in square "
35+
"brackets. Never invent places or details."
36+
)
37+
3138
_CITE_RE = re.compile(r"\[(\d+)\]")
3239

3340

@@ -52,6 +59,10 @@ def __init__(self, retriever: Retriever | None = None, llm: LLM | None = None):
5259
self.llm = llm or LLM()
5360

5461
def answer(self, question: str, max_steps: int = 5) -> AgentResult:
62+
# The tool-use loop targets Anthropic; the local (Ollama) path uses a
63+
# single-shot retrieve-then-answer, reliable on small models.
64+
if self.llm.settings.provider != "anthropic":
65+
return self._answer_rag(question)
5566
toolbox = Toolbox(self.retriever)
5667
messages: list[dict] = [{"role": "user", "content": question}]
5768
tool_calls_log: list[dict] = []
@@ -105,6 +116,28 @@ def answer(self, question: str, max_steps: int = 5) -> AgentResult:
105116
)
106117

107118

119+
def _answer_rag(self, question: str, k: int = 6) -> AgentResult:
120+
"""Single-shot retrieve-then-answer, used for the local-model path."""
121+
hits = self.retriever.search(question, k=k)
122+
context = "\n".join(f"[{h.place.id}] {h.place.document()}" for h in hits)
123+
prompt = (
124+
f"PLACES:\n{context}\n\nQUESTION: {question}\n\n"
125+
"Answer using ONLY the places above. After each factual claim, cite the "
126+
"place id in square brackets like [12]. If the places do not answer the "
127+
"question, say so plainly. Keep it to a few sentences."
128+
)
129+
resp = self.llm.message([{"role": "user", "content": prompt}], system=RAG_SYSTEM)
130+
cited = sorted({int(m) for m in _CITE_RE.findall(resp.text)})
131+
return AgentResult(
132+
question=question,
133+
answer=resp.text,
134+
cited_ids=cited,
135+
seen_ids=sorted({h.place.id for h in hits}),
136+
steps=1,
137+
tool_calls=[],
138+
)
139+
140+
108141
def _stringify(output: dict) -> str:
109142
import json
110143

agent/crypt_agent/api.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""HTTP service that exposes the Crypt agent to the web frontend.
2+
3+
Endpoints:
4+
GET /health -> readiness, whether an LLM key is present, corpus size
5+
POST /search {query} -> hybrid retrieval results with coordinates (no key)
6+
POST /ask {question} -> grounded, cited answer + the cited places (needs key)
7+
8+
The retriever (and its models) load once at startup and are shared across
9+
requests. Run with: ``uvicorn crypt_agent.api:app --port 8088``.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from functools import lru_cache
15+
16+
from fastapi import FastAPI, HTTPException
17+
from fastapi.middleware.cors import CORSMiddleware
18+
from pydantic import BaseModel
19+
20+
from .agent import CryptAgent
21+
from .config import load_settings
22+
from .corpus import Place
23+
from .llm import LLM, LLMUnavailable
24+
from .retrieval import Retriever
25+
26+
app = FastAPI(title="Crypt Agent", version="0.1.0")
27+
app.add_middleware(
28+
CORSMiddleware,
29+
allow_origins=["*"], # dev: the Next.js frontend is a separate origin
30+
allow_methods=["*"],
31+
allow_headers=["*"],
32+
)
33+
34+
35+
@lru_cache(maxsize=1)
36+
def _retriever() -> Retriever:
37+
return Retriever()
38+
39+
40+
@lru_cache(maxsize=1)
41+
def _agent() -> CryptAgent:
42+
return CryptAgent(retriever=_retriever(), llm=LLM())
43+
44+
45+
def _place_dto(place: Place) -> dict:
46+
return {
47+
"id": place.id,
48+
"name": place.name,
49+
"city": place.city,
50+
"state": place.state,
51+
"structure_type": place.structure_type,
52+
"lat": place.lat,
53+
"lng": place.lng,
54+
"description": place.description,
55+
}
56+
57+
58+
class SearchRequest(BaseModel):
59+
query: str
60+
state: str | None = None
61+
structure_type: str | None = None
62+
k: int = 6
63+
64+
65+
class AskRequest(BaseModel):
66+
question: str
67+
max_steps: int = 5
68+
69+
70+
@app.get("/health")
71+
def health() -> dict:
72+
settings = load_settings()
73+
active_model = settings.model if settings.provider == "anthropic" else settings.ollama_model
74+
return {
75+
"ok": True,
76+
"provider": settings.provider,
77+
"has_llm": settings.has_llm,
78+
"model": active_model,
79+
"corpus_size": len(_retriever().places),
80+
}
81+
82+
83+
@app.post("/search")
84+
def search(req: SearchRequest) -> dict:
85+
filters: dict = {}
86+
if req.state:
87+
filters["state"] = req.state
88+
if req.structure_type:
89+
filters["structure_type"] = req.structure_type
90+
hits = _retriever().search(req.query, k=req.k, filters=filters or None)
91+
return {
92+
"results": [
93+
{**_place_dto(h.place), "rerank_score": h.rerank_score} for h in hits
94+
]
95+
}
96+
97+
98+
@app.post("/ask")
99+
def ask(req: AskRequest) -> dict:
100+
try:
101+
result = _agent().answer(req.question, max_steps=req.max_steps)
102+
except LLMUnavailable as exc:
103+
raise HTTPException(status_code=503, detail=str(exc))
104+
places = _retriever().places
105+
cited = [_place_dto(places[i]) for i in result.cited_ids if 0 <= i < len(places)]
106+
return {
107+
"question": result.question,
108+
"answer": result.answer,
109+
"grounded": result.grounded,
110+
"steps": result.steps,
111+
"cited": cited,
112+
}

agent/crypt_agent/config.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,24 +29,40 @@
2929

3030
@dataclass(frozen=True)
3131
class Settings:
32-
"""Resolved settings for a single run."""
32+
"""Resolved settings for a single run.
3333
34+
``provider`` is "anthropic" when an API key is present, otherwise "ollama"
35+
(a free local model). Both back the same agent + judge code.
36+
"""
37+
38+
provider: str # "anthropic" | "ollama"
3439
anthropic_api_key: str | None
35-
model: str
40+
model: str # Anthropic model id
41+
ollama_host: str
42+
ollama_model: str
3643
embed_model: str
3744
rerank_model: str
3845
max_tokens: int
3946

4047
@property
4148
def has_llm(self) -> bool:
42-
return bool(self.anthropic_api_key)
49+
if self.provider == "anthropic":
50+
return bool(self.anthropic_api_key)
51+
return True # ollama path; availability is checked at call time
4352

4453

4554
def load_settings() -> Settings:
55+
key = os.environ.get("ANTHROPIC_API_KEY")
56+
provider = os.environ.get("CRYPT_LLM_PROVIDER", "auto")
57+
if provider == "auto":
58+
provider = "anthropic" if key else "ollama"
4659
return Settings(
47-
anthropic_api_key=os.environ.get("ANTHROPIC_API_KEY"),
48-
# Sonnet is the price/quality sweet spot for an agent loop + judge.
60+
provider=provider,
61+
anthropic_api_key=key,
62+
# Sonnet is the price/quality sweet spot for the hosted path.
4963
model=os.environ.get("CRYPT_AGENT_MODEL", "claude-sonnet-4-6"),
64+
ollama_host=os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
65+
ollama_model=os.environ.get("CRYPT_OLLAMA_MODEL", "llama3.2:3b"),
5066
embed_model=os.environ.get("CRYPT_EMBED_MODEL", "BAAI/bge-small-en-v1.5"),
5167
rerank_model=os.environ.get(
5268
"CRYPT_RERANK_MODEL", "cross-encoder/ms-marco-MiniLM-L-6-v2"

agent/crypt_agent/llm.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ def message(
5050
tools: list[dict] | None = None,
5151
max_tokens: int | None = None,
5252
) -> LLMResponse:
53+
if self.settings.provider == "ollama":
54+
return self._ollama_message(messages, system, max_tokens)
55+
return self._anthropic_message(messages, system, tools, max_tokens)
56+
57+
def _anthropic_message(self, messages, system, tools, max_tokens) -> LLMResponse:
5358
kwargs: dict = {
5459
"model": self.settings.model,
5560
"max_tokens": max_tokens or self.settings.max_tokens,
@@ -74,3 +79,29 @@ def message(
7479
stop_reason=resp.stop_reason,
7580
raw=resp,
7681
)
82+
83+
def _ollama_message(self, messages, system, max_tokens) -> LLMResponse:
84+
try:
85+
import ollama
86+
except ImportError as exc: # pragma: no cover
87+
raise LLMUnavailable("the 'ollama' package is not installed") from exc
88+
msgs = ([{"role": "system", "content": system}] if system else []) + list(messages)
89+
try:
90+
resp = ollama.Client(host=self.settings.ollama_host).chat(
91+
model=self.settings.ollama_model,
92+
messages=msgs,
93+
options={
94+
"temperature": 0.2,
95+
"num_predict": max_tokens or self.settings.max_tokens,
96+
},
97+
)
98+
except Exception as exc:
99+
raise LLMUnavailable(
100+
f"could not reach local Ollama model '{self.settings.ollama_model}'. "
101+
f"Is 'ollama serve' running and the model pulled? ({exc})"
102+
) from exc
103+
msg = resp["message"] if isinstance(resp, dict) else resp.message
104+
content = msg["content"] if isinstance(msg, dict) else msg.content
105+
return LLMResponse(
106+
text=(content or "").strip(), tool_calls=[], stop_reason="stop", raw=resp
107+
)

agent/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ dependencies = [
1111
"numpy>=1.24",
1212
"hnswlib>=0.8",
1313
"kagglehub>=0.3",
14+
"fastapi>=0.110",
15+
"uvicorn>=0.27",
16+
"pydantic>=2.0",
17+
"ollama>=0.3",
1418
]
1519

1620
[project.optional-dependencies]

agent/tests/test_agent.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ class FakeLLM:
2828
def __init__(self, responses):
2929
self.responses = list(responses)
3030
self.calls = 0
31+
# The agent reads provider to pick the tool-loop vs the RAG path.
32+
self.settings = SimpleNamespace(provider="anthropic")
3133

3234
def message(self, messages, system=None, tools=None, max_tokens=None):
3335
resp = self.responses[self.calls]
@@ -66,6 +68,19 @@ def test_agent_runs_tools_then_answers_grounded():
6668
assert result.tool_calls[0]["name"] == "search_places"
6769

6870

71+
def test_agent_rag_path_for_local_provider():
72+
# A non-anthropic provider uses single-shot retrieve-then-answer.
73+
llm = FakeLLM([_final("The most haunted hospital is Waverly Hills [0].")])
74+
llm.settings = SimpleNamespace(provider="ollama")
75+
agent = CryptAgent(retriever=FakeRetriever(), llm=llm)
76+
result = agent.answer("what is the most haunted hospital?")
77+
assert result.cited_ids == [0]
78+
assert result.seen_ids == [0]
79+
assert result.grounded is True
80+
assert result.steps == 1
81+
assert llm.calls == 1 # one shot, no tool loop
82+
83+
6984
def test_agent_flags_ungrounded_citation():
7085
# The model cites [1] but never retrieved it -> grounded must be False.
7186
llm = FakeLLM([

deploy/Dockerfile.web

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,8 @@ COPY --from=builder /app/.next ./.next
2020
COPY --from=builder /app/node_modules ./node_modules
2121
COPY --from=builder /app/package.json ./package.json
2222
COPY --from=builder /app/next.config.mjs ./next.config.mjs
23+
# Static assets in public/ (logo, etc.) must be present for `next start` to
24+
# serve them; without this, /brand.png and friends 404.
25+
COPY --from=builder /app/public ./public
2326
EXPOSE 3000
2427
CMD ["npm", "start"]

docs/images/app-detail.png

-42.5 KB
Loading

0 commit comments

Comments
 (0)