dokoissho's picture
Fix missing citations on HF + add visible site header
338d4f9
Raw
History Blame Contribute Delete
11.2 kB
import os
# Env defaults MUST be set before importing cognee — cognee reads config at import time.
os.environ.setdefault(
"SYSTEM_ROOT_DIRECTORY", os.path.join(os.path.dirname(__file__), ".cognee_system")
)
os.environ.setdefault("LLM_MODEL", "openai/gpt-4o-mini")
os.environ.setdefault("TELEMETRY_DISABLED", "1")
import asyncio # noqa: E402
import logging # noqa: E402
import re # noqa: E402
from datetime import datetime, timezone # noqa: E402
import gradio as gr # noqa: E402
import cognee # noqa: E402
from cognee.modules.search.types.SearchType import SearchType # noqa: E402
# ---- Config ----
DATASET = "manovich"
MAX_QUESTION_LEN = 2000
# Wallet safety net — global circuit breaker across all users per UTC day.
# Resets at UTC midnight or on container restart. Not auth, just a hard ceiling.
MAX_QUESTIONS_PER_DAY = 500
# Frozen DB stats (from cognee logs during last ingest). Update if DB is rebuilt.
STATS = {
"articles": 63,
"nodes": 3566,
"edges": 8205,
"span": "1992–2007",
}
ERA_FILTERS = {
"All years (1992–2007)": "",
"Early (1992–1998)": (
"When answering, focus on Manovich's writing between 1992 and 1998. "
),
"Middle (1999–2003)": (
"When answering, focus on Manovich's writing between 1999 and 2003. "
),
"Late (2004–2007)": (
"When answering, focus on Manovich's writing between 2004 and 2007. "
),
}
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
# Serialize cognee access — keeps behavior predictable under concurrent requests.
_search_lock = asyncio.Lock()
# Global per-day counter.
_daily_counter = {"date": None, "count": 0}
def _today_utc() -> str:
return datetime.now(timezone.utc).date().isoformat()
def _daily_cap_hit() -> bool:
if _daily_counter["date"] != _today_utc():
_daily_counter["date"] = _today_utc()
_daily_counter["count"] = 0
return _daily_counter["count"] >= MAX_QUESTIONS_PER_DAY
def _bump_daily() -> None:
if _daily_counter["date"] != _today_utc():
_daily_counter["date"] = _today_utc()
_daily_counter["count"] = 0
_daily_counter["count"] += 1
# ---- Search + citation extraction ----
def _unwrap_answer(result) -> str:
if hasattr(result, "search_result"):
items = result.search_result
elif isinstance(result, dict):
items = result.get("search_result", [])
else:
return str(result)
return "\n".join(items) if isinstance(items, list) else str(items)
_TITLE_RE = re.compile(r"^# (.+)$", re.MULTILINE)
_YEAR_RE = re.compile(r"_year:\s*(\d{4})_")
def _extract_sources(chunks_result) -> list[tuple[str, str | None]]:
"""Extract (title, year) tuples from a CHUNKS SearchResult.
The articles were ingested with a '# Title' heading and a '_year: YYYY_' tag
in their text, so we parse those out of each chunk. Chunks from the middle of
an article won't have this metadata and are skipped."""
if chunks_result is None:
return []
if isinstance(chunks_result, dict):
items = chunks_result.get("search_result", [])
elif hasattr(chunks_result, "search_result"):
items = chunks_result.search_result
else:
return []
if not items:
return []
out: list[tuple[str, str | None]] = []
for item in items:
text = item.get("text", "") if isinstance(item, dict) else getattr(item, "text", "")
if not text:
continue
t_match = _TITLE_RE.search(text)
if not t_match:
continue
title = t_match.group(1).strip()
y_match = _YEAR_RE.search(text)
year = y_match.group(1) if y_match else None
out.append((title, year))
return out
async def _search(question: str, era_prefix: str) -> tuple[str, list[tuple[str, str | None]]]:
prefixed = (era_prefix + question) if era_prefix else question
async with _search_lock:
# Run sequentially — concurrent cognee.search calls can contend on Kuzu locks.
answer_raw = await cognee.search(
query_text=prefixed,
query_type=SearchType.GRAPH_COMPLETION,
datasets=[DATASET],
)
try:
chunks_raw = await cognee.search(
query_text=question,
query_type=SearchType.CHUNKS,
datasets=[DATASET],
)
except Exception:
logger.exception("CHUNKS search failed — continuing without sources.")
chunks_raw = []
if not answer_raw:
return "No results found.", []
answer = _unwrap_answer(answer_raw[0])
raw_sources = _extract_sources(chunks_raw[0]) if chunks_raw else []
logger.info(
"search: q=%r chunks_len=%d raw_sources=%d",
question[:60],
len(chunks_raw[0].get("search_result", [])) if chunks_raw and isinstance(chunks_raw[0], dict) else 0,
len(raw_sources),
)
# Dedupe by title, preserving order (highest-similarity chunks come first).
seen: set[str] = set()
uniq: list[tuple[str, str | None]] = []
for title, year in raw_sources:
if title not in seen:
seen.add(title)
uniq.append((title, year))
return answer, uniq[:3]
def _format_answer(answer: str, sources: list[tuple[str, str | None]]) -> str:
if not sources:
return answer
lines = []
for title, year in sources:
if year:
lines.append(f"- *{title}* ({year})")
else:
lines.append(f"- *{title}*")
return f"{answer}\n\n---\n**Drawing from:**\n" + "\n".join(lines)
# ---- Gradio handlers ----
async def respond(message: str, history: list, era: str):
"""Async generator yielding incremental history updates."""
history = history or []
message = (message or "").strip()
def _with(content: str) -> list:
return history + [
{"role": "user", "content": message},
{"role": "assistant", "content": content},
]
if not message:
yield history
return
if len(message) > MAX_QUESTION_LEN:
yield _with(f"Question too long (>{MAX_QUESTION_LEN} characters). Please shorten it.")
return
if _daily_cap_hit():
yield _with("The demo has reached its daily usage cap. Please try again tomorrow.")
return
# Progress placeholder.
progress = _with(
f"*Searching across {STATS['articles']} articles and ~{STATS['edges']:,} relationships…*"
)
yield progress
try:
answer, sources = await _search(message, ERA_FILTERS.get(era, ""))
_bump_daily()
final = progress.copy()
final[-1] = {"role": "assistant", "content": _format_answer(answer, sources)}
yield final
except Exception:
logger.exception("Search failed for message: %r", message)
err = progress.copy()
err[-1] = {
"role": "assistant",
"content": "Sorry — something went wrong answering that. Please try again.",
}
yield err
def _clear_chat():
return [], ""
def _load_from_query_string(request: gr.Request | None):
"""Preload question text from ?q=… for shareable links."""
if request is None:
return ""
try:
params = dict(request.query_params) if request.query_params else {}
except Exception:
params = {}
return params.get("q", "") or ""
# ---- UI ----
HEADER_HTML = """\
<div id="mkg-header">
<h1>Lev Manovich — Knowledge Graph</h1>
<p class="mkg-sub">
Ask cross-article questions across 63 essays, 1992–2007.
Built with <a href="https://github.com/topoteretes/cognee" target="_blank">cognee</a>.
</p>
</div>
"""
CUSTOM_CSS = """
/* Kill Gradio's oversized top padding so the header is visible on load. */
.gradio-container { padding-top: 0 !important; }
footer { display: none !important; }
#mkg-header {
background: linear-gradient(135deg, #1e3a8a 0%, #4338ca 100%);
color: white;
padding: 24px 28px;
margin: 0 0 16px 0;
border-radius: 8px;
}
#mkg-header h1 {
margin: 0 0 6px 0;
font-size: 1.75rem;
line-height: 1.2;
color: white;
font-weight: 600;
}
#mkg-header .mkg-sub {
margin: 0;
color: rgba(255,255,255,0.85);
font-size: 0.95rem;
}
#mkg-header a {
color: #c7d2fe;
text-decoration: underline;
}
"""
ABOUT = f"""\
This demo queries a knowledge graph built from **{STATS['articles']} articles**
Lev Manovich published between **{STATS['span']}**. The graph contains roughly
**{STATS['nodes']:,} entities** and **{STATS['edges']:,} relationships**. Each
question triggers a graph traversal to gather relevant context, which an LLM
(GPT-4o-mini) then synthesizes into an answer.
Answers are not generated from any single article — they assemble evidence
across the corpus. The *Drawing from* lines beneath each answer show the
articles cognee leaned on most.
"""
FOOTER = """\
---
*Corpus: Lev Manovich's collected essays, 1992–2007. Knowledge graph extracted
and queried via [cognee](https://github.com/topoteretes/cognee). Answers synthesized
by GPT-4o-mini. This is a demo — answers may contain inaccuracies.*
"""
EXAMPLES = [
"What does Manovich mean by the term 'velvet revolution'?",
"What does Manovich mean by 'deep remixability'?",
"What is 'navigable space' and why does it matter?",
"What is the relationship between cinema and software in his work?",
"How does Manovich define new media?",
"What thinkers does Manovich draw on most, and for what purposes?",
"How does his thinking change between 1992 and 2007?",
]
with gr.Blocks(
title="Manovich Knowledge Graph",
theme=gr.themes.Soft(),
css=CUSTOM_CSS,
) as demo:
gr.HTML(HEADER_HTML)
with gr.Accordion("About this graph", open=False):
gr.Markdown(ABOUT)
chatbot = gr.Chatbot(type="messages", height=480, show_label=False)
with gr.Row():
era = gr.Dropdown(
choices=list(ERA_FILTERS.keys()),
value="All years (1992–2007)",
label="Period filter (advisory — nudges the answer, doesn't hard-filter retrieval)",
scale=1,
)
msg = gr.Textbox(
placeholder="Ask a question about Manovich's work…",
show_label=False,
autofocus=True,
lines=2,
)
with gr.Row():
submit_btn = gr.Button("Ask", variant="primary")
clear_btn = gr.Button("Clear")
gr.Markdown("**Try one of these:**")
gr.Examples(examples=EXAMPLES, inputs=msg)
gr.Markdown(FOOTER)
# Wire up handlers. .then(lambda: "", outputs=msg) clears the textbox after submit.
msg.submit(respond, [msg, chatbot, era], [chatbot]).then(lambda: "", outputs=msg)
submit_btn.click(respond, [msg, chatbot, era], [chatbot]).then(lambda: "", outputs=msg)
clear_btn.click(_clear_chat, outputs=[chatbot, msg])
# Shareable links: populate textbox from ?q= on first load.
demo.load(_load_from_query_string, outputs=msg)
demo.queue(max_size=10, default_concurrency_limit=2)
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860)