French-Coach / notebook.py
Asma-F's picture
Fix word card (hidden-button trigger), add Tools side panel + manual link form
bfb5cd4 verified
Raw
History Blame Contribute Delete
6.55 kB
import json
from datetime import date
from db import get_cursor
import curator
def save_page(user_id: str, raw_text: str, annotations: dict) -> tuple[str, str]:
"""Curates the page (title/summary/type/resources), inserts into DB. Returns (page_id, title)."""
import nlp as _nlp
curated = curator.curate_page(raw_text)
category = _nlp.detect_category(raw_text[:300])
title = curated["title"]
metadata = {
"category": category,
"summary": curated["summary"],
"page_type": curated["page_type"],
"links": curated["links"],
"books": curated["books"],
}
with get_cursor() as cur:
cur.execute(
"""INSERT INTO pages (user_id, title, date, raw_text, annotations, metadata)
VALUES (%s, %s, %s, %s, %s, %s) RETURNING id::text""",
(user_id, title, date.today(), raw_text,
json.dumps(annotations), json.dumps(metadata)),
)
page_id = cur.fetchone()["id"]
return page_id, title
def list_pages(user_id: str) -> list[dict]:
"""Sidebar list — returns [{id, title, date, category, page_type, preview}] newest-first, max 50.
Fetches the first 300 chars of raw_text to detect category and build a hover
preview without pulling the full lesson text over the wire.
"""
import nlp as _nlp
with get_cursor() as cur:
cur.execute(
"SELECT id::text, title, date::text, "
"LEFT(raw_text, 300) AS snippet, "
"COALESCE(metadata->>'category', '') AS stored_category, "
"COALESCE(metadata->>'page_type', 'lesson') AS page_type "
"FROM pages WHERE user_id = %s ORDER BY created_at DESC LIMIT 50",
(user_id,),
)
rows = [dict(r) for r in cur.fetchall()]
for row in rows:
snippet = row.pop("snippet", "") or ""
stored_cat = row.pop("stored_category", "") or ""
row["category"] = stored_cat if stored_cat else _nlp.detect_category(snippet)
preview = snippet[:100]
row["preview"] = preview + ("…" if len(snippet) > 100 else "")
return rows
def list_resources(user_id: str) -> list[dict]:
"""Return resource-type pages: [{id, title, links, books}], newest-first."""
with get_cursor() as cur:
cur.execute(
"SELECT id::text, title, "
"COALESCE(metadata->'links', '[]'::jsonb) AS links, "
"COALESCE(metadata->'books', '[]'::jsonb) AS books "
"FROM pages WHERE user_id = %s AND metadata->>'page_type' = 'resource' "
"ORDER BY created_at DESC",
(user_id,),
)
return [dict(r) for r in cur.fetchall()]
def update_title(page_id: str, user_id: str, title: str) -> str:
"""Persist a user-edited title (overrides the auto-generated one). Returns the saved title."""
title = (title or "").strip()[:80] or "Untitled Lesson"
with get_cursor() as cur:
cur.execute(
"UPDATE pages SET title = %s WHERE id = %s AND user_id = %s",
(title, page_id, user_id),
)
return title
def get_page(page_id: str, user_id: str) -> dict | None:
with get_cursor() as cur:
cur.execute(
"SELECT id::text, title, raw_text, annotations "
"FROM pages WHERE id = %s AND user_id = %s",
(page_id, user_id),
)
row = cur.fetchone()
return dict(row) if row else None
def update_annotations(page_id: str, annotations: dict) -> None:
"""Persist LLM-enriched annotations back to the page row."""
with get_cursor() as cur:
cur.execute(
"UPDATE pages SET annotations = %s WHERE id = %s",
(json.dumps(annotations), page_id),
)
def update_page(page_id: str, user_id: str, raw_text: str, annotations: dict) -> str:
"""Update page content in-place, keep existing title. Returns the title."""
with get_cursor() as cur:
cur.execute(
"SELECT title FROM pages WHERE id = %s AND user_id = %s",
(page_id, user_id),
)
row = cur.fetchone()
title = row["title"] if row else raw_text.split("\n")[0][:60]
with get_cursor() as cur:
cur.execute(
"UPDATE pages SET raw_text = %s, annotations = %s WHERE id = %s AND user_id = %s",
(raw_text, json.dumps(annotations), page_id, user_id),
)
return title
def add_manual_link(user_id: str, title: str, url: str) -> None:
"""Append a manually-added link to the user's pinned 'My Resources' page.
Upserts a single resource page titled 'My Resources' — the page_type is
'resource' so it shows in the Resources tab, never in the lesson sidebar.
"""
title = (title or "").strip()[:120] or url
url = (url or "").strip()
if not url:
return
new_link = {"label": title, "url": url}
with get_cursor() as cur:
# Find existing pinned resource page for this user
cur.execute(
"SELECT id::text, metadata FROM pages "
"WHERE user_id = %s AND metadata->>'page_type' = 'resource' "
"AND title = 'My Resources' ORDER BY created_at ASC LIMIT 1",
(user_id,),
)
row = cur.fetchone()
if row:
meta = dict(row["metadata"]) if row["metadata"] else {}
links = list(meta.get("links") or [])
links.append(new_link)
meta["links"] = links
cur.execute(
"UPDATE pages SET metadata = %s WHERE id = %s",
(json.dumps(meta), row["id"]),
)
else:
meta = {
"category": "Resources",
"summary": "Manually saved online resources",
"page_type": "resource",
"links": [new_link],
"books": [],
}
cur.execute(
"""INSERT INTO pages (user_id, title, date, raw_text, annotations, metadata)
VALUES (%s, %s, %s, %s, %s, %s)""",
(user_id, "My Resources", date.today(),
"Manually saved resources", json.dumps({}), json.dumps(meta)),
)
def delete_page(page_id: str, user_id: str) -> bool:
"""Delete page row (exercises cascade). Returns True if a row was deleted."""
with get_cursor() as cur:
cur.execute(
"DELETE FROM pages WHERE id = %s AND user_id = %s RETURNING id",
(page_id, user_id),
)
return cur.fetchone() is not None