monograph 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- monograph/__init__.py +11 -0
- monograph/app.py +113 -0
- monograph/arc/__init__.py +21 -0
- monograph/arc/analyze.py +64 -0
- monograph/arc/recon.py +137 -0
- monograph/arc/scenes.py +172 -0
- monograph/arc/spine.py +80 -0
- monograph/cli.py +187 -0
- monograph/config.py +67 -0
- monograph/index/__init__.py +25 -0
- monograph/index/base.py +22 -0
- monograph/index/embedding.py +46 -0
- monograph/index/local.py +77 -0
- monograph/ingest/__init__.py +4 -0
- monograph/ingest/parser.py +251 -0
- monograph/llm/__init__.py +22 -0
- monograph/llm/base.py +28 -0
- monograph/llm/ollama_backend.py +51 -0
- monograph/narrate/__init__.py +26 -0
- monograph/narrate/audio.py +55 -0
- monograph/narrate/backends/__init__.py +2 -0
- monograph/narrate/backends/f5.py +32 -0
- monograph/narrate/backends/xtts.py +50 -0
- monograph/narrate/base.py +69 -0
- monograph/narrate/chapterize.py +73 -0
- monograph/narrate/pronunciation.py +34 -0
- monograph/narrate/voice.py +63 -0
- monograph/types.py +131 -0
- monograph/write/__init__.py +6 -0
- monograph/write/engine.py +164 -0
- monograph/write/style.py +15 -0
- monograph-0.1.0.dist-info/METADATA +200 -0
- monograph-0.1.0.dist-info/RECORD +35 -0
- monograph-0.1.0.dist-info/WHEEL +4 -0
- monograph-0.1.0.dist-info/entry_points.txt +2 -0
monograph/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""monograph โ diary -> memoir -> narrated audio, local-first.
|
|
2
|
+
|
|
3
|
+
Only light things are imported at top level so `import monograph` stays cheap and
|
|
4
|
+
free of heavy optional dependencies. Pull backends from their submodules
|
|
5
|
+
(`monograph.narrate`, `monograph.index`, ...) when you need them.
|
|
6
|
+
"""
|
|
7
|
+
from .config import Config
|
|
8
|
+
from . import types
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["Config", "types", "__version__"]
|
monograph/app.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Diary Companion โ a local, private Streamlit app for exploring a journal:
|
|
2
|
+
find forgotten entries, follow a thread across the years, see your story freshly.
|
|
3
|
+
Ported from diary_app.py. Retrieval goes through monograph's LocalIndex (no faiss
|
|
4
|
+
/ pickle), and the family age context is optional config, not hardcoded.
|
|
5
|
+
|
|
6
|
+
Launch it with: monograph app
|
|
7
|
+
|
|
8
|
+
It reads an index built by `monograph index` (default base name "diary_index").
|
|
9
|
+
Age labels appear if a monograph.toml with a [people] table sits in the folder:
|
|
10
|
+
|
|
11
|
+
[people]
|
|
12
|
+
Cash = "2007-01-10"
|
|
13
|
+
Camille = "2008-01-31"
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
import os
|
|
17
|
+
from datetime import date
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import streamlit as st
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@st.cache_resource
|
|
24
|
+
def _load_index(base: str):
|
|
25
|
+
from monograph.index.local import LocalIndex
|
|
26
|
+
return LocalIndex.load(base)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_people() -> dict:
|
|
30
|
+
try:
|
|
31
|
+
import tomllib
|
|
32
|
+
p = Path("monograph.toml")
|
|
33
|
+
if p.exists():
|
|
34
|
+
data = tomllib.loads(p.read_text(encoding="utf-8"))
|
|
35
|
+
return {k: date.fromisoformat(v) for k, v in data.get("people", {}).items()}
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _age_at(birth: date, d: date) -> int:
|
|
42
|
+
return d.year - birth.year - ((d.month, d.day) < (birth.month, birth.day))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run():
|
|
46
|
+
st.set_page_config(page_title="Diary Companion", page_icon="๐", layout="wide")
|
|
47
|
+
st.title("๐ Diary Companion")
|
|
48
|
+
st.caption("A private, local space to find, revisit, and reflect on your own "
|
|
49
|
+
"entries. Nothing leaves this machine.")
|
|
50
|
+
|
|
51
|
+
base = st.sidebar.text_input("Index path",
|
|
52
|
+
value=os.environ.get("MONOGRAPH_INDEX", "diary_index"))
|
|
53
|
+
try:
|
|
54
|
+
idx = _load_index(base)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
st.error(f"Couldn't load an index at '{base}'. Build one first:\n\n"
|
|
57
|
+
f"`monograph index --diary <your diary> --out {base}`\n\n({e})")
|
|
58
|
+
st.stop()
|
|
59
|
+
|
|
60
|
+
people = _load_people()
|
|
61
|
+
dated = [e for e in idx.entries if e.entry_date]
|
|
62
|
+
span = (f"{min(e.entry_date for e in dated)} โ {max(e.entry_date for e in dated)}"
|
|
63
|
+
if dated else "no dates")
|
|
64
|
+
st.sidebar.success(f"{len(idx.entries)} entries loaded ({span})")
|
|
65
|
+
|
|
66
|
+
st.sidebar.header("Find a thread")
|
|
67
|
+
theme = st.sidebar.text_input("Search by theme or feeling",
|
|
68
|
+
placeholder="e.g. a recovery, feeling alone, hope")
|
|
69
|
+
k = st.sidebar.slider("How many entries to surface", 5, 100, 30, step=5)
|
|
70
|
+
min_score = st.sidebar.slider("Relevance threshold", 0.0, 0.6, 0.20, step=0.02,
|
|
71
|
+
help="Higher = only stronger matches.")
|
|
72
|
+
order = st.sidebar.radio("Show in order of", ["Chronological", "Relevance"])
|
|
73
|
+
|
|
74
|
+
if not theme:
|
|
75
|
+
st.info("Enter a theme or feeling in the sidebar to surface related entries "
|
|
76
|
+
"from across your journal, shown with their dates so you can follow "
|
|
77
|
+
"how a thread developed over time.")
|
|
78
|
+
st.stop()
|
|
79
|
+
|
|
80
|
+
hits = [(e, s) for e, s in idx.search(theme, k=k) if s >= min_score]
|
|
81
|
+
if not hits:
|
|
82
|
+
st.warning("No entries matched. Lower the relevance threshold or rephrase.")
|
|
83
|
+
st.stop()
|
|
84
|
+
|
|
85
|
+
if order == "Chronological":
|
|
86
|
+
hits.sort(key=lambda es: es[0].entry_date or date.min)
|
|
87
|
+
else:
|
|
88
|
+
hits.sort(key=lambda es: es[1], reverse=True)
|
|
89
|
+
|
|
90
|
+
st.subheader(f"{len(hits)} entries related to โ{theme}โ")
|
|
91
|
+
years = [e.entry_date.year for e, _ in hits if e.entry_date]
|
|
92
|
+
if years:
|
|
93
|
+
counts = {y: years.count(y) for y in range(min(years), max(years) + 1)}
|
|
94
|
+
st.caption("When these entries fall (a first glimpse of the pattern):")
|
|
95
|
+
st.bar_chart({"entries": counts})
|
|
96
|
+
st.divider()
|
|
97
|
+
|
|
98
|
+
for e, score in hits:
|
|
99
|
+
d = e.entry_date
|
|
100
|
+
weekday = e.meta.get("weekday") or (d.strftime("%A") if d else "")
|
|
101
|
+
cols = st.columns([3, 1])
|
|
102
|
+
cols[0].markdown(f"**{d or '?'} ยท {weekday}**")
|
|
103
|
+
if people and d:
|
|
104
|
+
ages = " ยท ".join(f"{n} {_age_at(b, d)}" for n, b in people.items())
|
|
105
|
+
cols[1].markdown(f"<div style='text-align:right;color:gray;font-size:0.8em'>"
|
|
106
|
+
f"{ages}</div>", unsafe_allow_html=True)
|
|
107
|
+
st.write(e.text)
|
|
108
|
+
st.caption(f"relevance {score:.2f}")
|
|
109
|
+
st.divider()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
run()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""arc โ the memoir-structure engine: distill a diary into signals, write a
|
|
2
|
+
reconnaissance report, and propose candidate spines. This is the heart of
|
|
3
|
+
monograph, ported from memoir_recon.py and spine_proposer.py and generalized so
|
|
4
|
+
all domain content lives in a `Lenses` object the writer supplies.
|
|
5
|
+
|
|
6
|
+
from monograph.index import get_embedder
|
|
7
|
+
from monograph.llm import get_llm
|
|
8
|
+
from monograph.types import Lenses
|
|
9
|
+
|
|
10
|
+
embed = get_embedder("local")
|
|
11
|
+
report = recon_report(entries, embed, lenses=Lenses.default()) # markdown
|
|
12
|
+
evidence = build_evidence(entries, embed, lenses=Lenses.default())
|
|
13
|
+
spines = propose_spines(evidence, get_llm("ollama", cfg)) # candidate books
|
|
14
|
+
"""
|
|
15
|
+
from .analyze import build_evidence
|
|
16
|
+
from .recon import recon_report
|
|
17
|
+
from .spine import propose_spines
|
|
18
|
+
from .scenes import scenes_by_aspect, scenes_report
|
|
19
|
+
|
|
20
|
+
__all__ = ["build_evidence", "recon_report", "propose_spines",
|
|
21
|
+
"scenes_by_aspect", "scenes_report"]
|
monograph/arc/analyze.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Distill a diary into an Evidence pack โ aspect prevalence and trend, a
|
|
2
|
+
timeline, turning points, recurring people, and a capped set of representative
|
|
3
|
+
snippets. This is the structured, privacy-preserving summary handed to the LLM
|
|
4
|
+
in propose_spines (the whole journal is never sent). Ported from
|
|
5
|
+
spine_proposer.build_evidence; all domain content comes from Lenses.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ..types import DiaryEntry, Lenses, Evidence
|
|
11
|
+
from .scenes import build_frame, extract_entities, turning_points, snippet
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_evidence(entries: list[DiaryEntry], embed, lenses: Lenses | None = None,
|
|
15
|
+
min_year: int | None = None, max_year: int | None = None,
|
|
16
|
+
per_aspect: int = 3, entry_embeddings=None) -> Evidence:
|
|
17
|
+
lenses = lenses or Lenses.default()
|
|
18
|
+
df, E = build_frame(entries, lenses, embed, entry_embeddings)
|
|
19
|
+
|
|
20
|
+
yr = df["dt"].dt.year
|
|
21
|
+
if min_year is None and max_year is None:
|
|
22
|
+
df["valid"] = df["dt"].notna()
|
|
23
|
+
else:
|
|
24
|
+
lo = min_year if min_year is not None else -(10 ** 9)
|
|
25
|
+
hi = max_year if max_year is not None else 10 ** 9
|
|
26
|
+
df["valid"] = df["dt"].notna() & yr.between(lo, hi)
|
|
27
|
+
valid = df[df["valid"]]
|
|
28
|
+
vidx = valid.index.to_numpy()
|
|
29
|
+
|
|
30
|
+
order = valid.sort_values("dt").index.to_numpy()
|
|
31
|
+
half = len(order) // 2
|
|
32
|
+
early, late = set(order[:half]), set(order[half:])
|
|
33
|
+
|
|
34
|
+
aspects, ref_ids = [], set()
|
|
35
|
+
for a in lenses.aspects:
|
|
36
|
+
share = (valid["top_aspect"] == a).mean() * 100 if len(valid) else 0.0
|
|
37
|
+
e_share = np.mean([df.at[i, "top_aspect"] == a for i in early]) if early else 0
|
|
38
|
+
l_share = np.mean([df.at[i, "top_aspect"] == a for i in late]) if late else 0
|
|
39
|
+
trend = ("rising" if l_share > e_share * 1.25
|
|
40
|
+
else "falling" if l_share < e_share * 0.8 else "steady")
|
|
41
|
+
s = df.loc[vidx, "asp_" + a].to_numpy() if len(vidx) else np.array([])
|
|
42
|
+
top = vidx[s.argsort()[::-1][:per_aspect]] if len(vidx) else np.array([], dtype=int)
|
|
43
|
+
ref_ids.update(int(i) for i in top)
|
|
44
|
+
aspects.append({"name": a, "share_pct": round(float(share), 1),
|
|
45
|
+
"trend": trend, "top_ids": [int(i) for i in top]})
|
|
46
|
+
|
|
47
|
+
tps, _ = turning_points(valid)
|
|
48
|
+
turning = []
|
|
49
|
+
for t, z in tps:
|
|
50
|
+
mask = valid["dt"].dt.to_period("M") == t.to_period("M")
|
|
51
|
+
ids = valid.index[mask].to_numpy()[:2]
|
|
52
|
+
ref_ids.update(int(i) for i in ids)
|
|
53
|
+
turning.append({"month": t.strftime("%Y-%m"), "z": round(z, 2),
|
|
54
|
+
"ids": [int(i) for i in ids]})
|
|
55
|
+
|
|
56
|
+
entities = extract_entities(valid["text"], lenses.name_stop)
|
|
57
|
+
timeline = {int(y): int(c)
|
|
58
|
+
for y, c in valid["dt"].dt.year.value_counts().sort_index().items()}
|
|
59
|
+
snippets = {int(i): f"{df.at[i, 'dt'].date().isoformat()} โ {snippet(df.at[i, 'text'])}"
|
|
60
|
+
for i in sorted(ref_ids)}
|
|
61
|
+
|
|
62
|
+
return Evidence(n_entries=int(len(df)), n_valid=int(len(valid)), aspects=aspects,
|
|
63
|
+
timeline=timeline, turning_points=turning, entities=entities,
|
|
64
|
+
snippets=snippets)
|
monograph/arc/recon.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Reconnaissance report over a diary: split into optional strands, cluster each
|
|
2
|
+
into recurring situation-types, score aspects, trace the emotional trajectory,
|
|
3
|
+
flag turning points, and (for a compare/contrast memoir) pair one strand's
|
|
4
|
+
situations with their nearest echoes in another. Returns Markdown.
|
|
5
|
+
|
|
6
|
+
Ported from memoir_recon.py. The present/past split there is now just a two-entry
|
|
7
|
+
`Lenses.strands`; with no strands it degrades to a single-strand report.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from ..types import DiaryEntry, Lenses
|
|
13
|
+
from .scenes import build_frame, centroid, top_terms, kmeans, snippet, turning_points
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def recon_report(entries: list[DiaryEntry], embed, lenses: Lenses | None = None,
|
|
17
|
+
spine_percentile: float = 55, min_year: int | None = None,
|
|
18
|
+
max_year: int | None = None, entry_embeddings=None) -> str:
|
|
19
|
+
import pandas as pd
|
|
20
|
+
lenses = lenses or Lenses.default()
|
|
21
|
+
df, E = build_frame(entries, lenses, embed, entry_embeddings)
|
|
22
|
+
|
|
23
|
+
strand_names = list(lenses.strands.keys())
|
|
24
|
+
if strand_names:
|
|
25
|
+
S = np.stack([E @ centroid(embed(seeds)) for seeds in lenses.strands.values()], 1)
|
|
26
|
+
df["strand"] = [strand_names[i] for i in S.argmax(1)]
|
|
27
|
+
relevance = S.max(1)
|
|
28
|
+
else:
|
|
29
|
+
df["strand"] = "all"
|
|
30
|
+
relevance = df[["asp_" + a for a in lenses.aspects]].to_numpy().max(1)
|
|
31
|
+
df["spine"] = relevance >= np.quantile(relevance, spine_percentile / 100.0)
|
|
32
|
+
|
|
33
|
+
out = ["# Memoir reconnaissance", "",
|
|
34
|
+
f"{len(df)} entries ยท {int(df['dt'].notna().sum())} dated ยท "
|
|
35
|
+
f"spine subset: {int(df['spine'].sum())} entries "
|
|
36
|
+
f"(top {100 - spine_percentile:.0f}% by relevance).", ""]
|
|
37
|
+
if strand_names:
|
|
38
|
+
out += ["Strands: " + " vs. ".join(strand_names), ""]
|
|
39
|
+
|
|
40
|
+
# strands over time
|
|
41
|
+
if strand_names:
|
|
42
|
+
dd = df[df["spine"] & df["dt"].notna()].copy()
|
|
43
|
+
dd["year"] = dd["dt"].dt.year
|
|
44
|
+
pivot = dd.pivot_table(index="year", columns="strand", values="text",
|
|
45
|
+
aggfunc="count", fill_value=0)
|
|
46
|
+
out += ["## Strands over time (spine entries per year)", "",
|
|
47
|
+
"| year | " + " | ".join(strand_names) + " |",
|
|
48
|
+
"|---|" + "---|" * len(strand_names)]
|
|
49
|
+
for y, row in pivot.iterrows():
|
|
50
|
+
out.append("| " + str(y) + " | "
|
|
51
|
+
+ " | ".join(str(int(row.get(n, 0))) for n in strand_names) + " |")
|
|
52
|
+
out.append("")
|
|
53
|
+
|
|
54
|
+
# clusters per strand
|
|
55
|
+
def cluster_block(strand, title):
|
|
56
|
+
idx = df.index[df["spine"] & (df["strand"] == strand)].to_numpy()
|
|
57
|
+
if len(idx) < 6:
|
|
58
|
+
return [f"## {title}", "", "_(too few entries to cluster)_", ""]
|
|
59
|
+
labels, k = kmeans(E[idx], k=len(idx) // 30 + 3)
|
|
60
|
+
block = [f"## {title} ({len(idx)} entries, {k} themes)", ""]
|
|
61
|
+
for c in range(k):
|
|
62
|
+
members = idx[labels == c]
|
|
63
|
+
if len(members) == 0:
|
|
64
|
+
continue
|
|
65
|
+
sub_c = centroid(E[members])
|
|
66
|
+
order = members[(E[members] @ sub_c).argsort()[::-1]]
|
|
67
|
+
terms = top_terms([df.at[i, "text"] for i in members])
|
|
68
|
+
yrs = df.loc[members, "dt"].dt.year.dropna()
|
|
69
|
+
span = f"{int(yrs.min())}-{int(yrs.max())}" if len(yrs) else "undated"
|
|
70
|
+
block.append(f"**Theme {c + 1}** ({len(members)} entries, {span}) โ {terms}")
|
|
71
|
+
for i in order[:2]:
|
|
72
|
+
d = df.at[i, "dt"]
|
|
73
|
+
ds = d.date().isoformat() if pd.notna(d) else "?"
|
|
74
|
+
block.append(f" - *{ds}*: {snippet(df.at[i, 'text'])}")
|
|
75
|
+
block.append("")
|
|
76
|
+
return block
|
|
77
|
+
|
|
78
|
+
for strand in (strand_names or ["all"]):
|
|
79
|
+
label = strand if strand != "all" else "recurring situations"
|
|
80
|
+
out += cluster_block(strand, f"{label} โ recurring situations")
|
|
81
|
+
|
|
82
|
+
# aspect distribution
|
|
83
|
+
out += ["## Aspects across the spine", "", "| aspect | entries |", "|---|---|"]
|
|
84
|
+
for a, n in df[df["spine"]]["top_aspect"].value_counts().items():
|
|
85
|
+
out.append(f"| {a} | {int(n)} |")
|
|
86
|
+
out.append("")
|
|
87
|
+
|
|
88
|
+
# compare / contrast between the first two strands
|
|
89
|
+
if len(strand_names) >= 2:
|
|
90
|
+
a_name, b_name = strand_names[0], strand_names[1]
|
|
91
|
+
a_idx = df.index[df["spine"] & (df["strand"] == a_name)].to_numpy()
|
|
92
|
+
b_idx = df.index[df["spine"] & (df["strand"] == b_name)].to_numpy()
|
|
93
|
+
out += [f"## Compare / contrast โ {a_name} situations and their {b_name} echoes", ""]
|
|
94
|
+
if len(a_idx) >= 6 and len(b_idx) >= 1:
|
|
95
|
+
labels, k = kmeans(E[a_idx], k=len(a_idx) // 30 + 3)
|
|
96
|
+
for c in range(k):
|
|
97
|
+
members = a_idx[labels == c]
|
|
98
|
+
if len(members) == 0:
|
|
99
|
+
continue
|
|
100
|
+
cc = centroid(E[members])
|
|
101
|
+
rep = members[(E[members] @ cc).argmax()]
|
|
102
|
+
echoes = b_idx[(E[b_idx] @ cc).argsort()[::-1][:2]]
|
|
103
|
+
drep = df.at[rep, "dt"]
|
|
104
|
+
out.append(f"**{a_name}:** *{drep.date().isoformat() if pd.notna(drep) else '?'}*: "
|
|
105
|
+
f"{snippet(df.at[rep, 'text'])}")
|
|
106
|
+
for e in echoes:
|
|
107
|
+
de = df.at[e, "dt"]
|
|
108
|
+
out.append(f" - **{b_name} echo** "
|
|
109
|
+
f"*{de.date().isoformat() if pd.notna(de) else '?'}*: "
|
|
110
|
+
f"{snippet(df.at[e, 'text'])}")
|
|
111
|
+
out.append("")
|
|
112
|
+
else:
|
|
113
|
+
out.append("_(not enough paired material โ lower spine_percentile)_\n")
|
|
114
|
+
|
|
115
|
+
# emotional trajectory + turning points
|
|
116
|
+
md = df[df["spine"] & df["dt"].notna()].copy()
|
|
117
|
+
tps, monthly = turning_points(md)
|
|
118
|
+
out += ["## Emotional trajectory (spine entries, monthly mean valence)", "",
|
|
119
|
+
"Valence > 0 leans warm/positive, < 0 leans hard/negative.", ""]
|
|
120
|
+
if len(monthly) >= 6:
|
|
121
|
+
yr = monthly.resample("YS").mean()
|
|
122
|
+
out += ["| year | mean valence |", "|---|---|"]
|
|
123
|
+
for d, v in yr.items():
|
|
124
|
+
out.append(f"| {d.year} | {v:+.3f} |")
|
|
125
|
+
out.append("")
|
|
126
|
+
out += ["### Candidate turning points (largest month-to-month swings)", ""]
|
|
127
|
+
mi = md.set_index("dt").sort_index()
|
|
128
|
+
for t, z in tps:
|
|
129
|
+
near = mi[(mi.index.year == t.year) & (mi.index.month == t.month)]
|
|
130
|
+
out.append(f"**{t.strftime('%B %Y')}** (ฮvalence z={z:+.2f})")
|
|
131
|
+
for i in range(min(2, len(near))):
|
|
132
|
+
out.append(f" - {snippet(near.iloc[i]['text'])}")
|
|
133
|
+
out.append("")
|
|
134
|
+
else:
|
|
135
|
+
out.append("_(not enough dated spine entries for a trajectory)_\n")
|
|
136
|
+
|
|
137
|
+
return "\n".join(out)
|
monograph/arc/scenes.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Signal extraction shared by the arc stage: build the scored frame of entries,
|
|
2
|
+
cluster them into recurring situation-types (scenes/themes), score aspects and
|
|
3
|
+
valence, find turning points, and spot recurring people. Generalized from
|
|
4
|
+
memoir_recon.py / spine_proposer.py โ all domain content comes in via Lenses.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import re
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ..types import DiaryEntry, Lenses, Scene
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def snippet(t: str, n: int = 260) -> str:
|
|
14
|
+
t = re.sub(r"\s+", " ", t or "").strip()
|
|
15
|
+
return t[:n] + ("โฆ" if len(t) > n else "")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def centroid(vecs: np.ndarray) -> np.ndarray:
|
|
19
|
+
c = vecs.mean(axis=0)
|
|
20
|
+
nrm = np.linalg.norm(c)
|
|
21
|
+
return c / nrm if nrm else c
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def top_terms(texts, k: int = 6) -> str:
|
|
25
|
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
26
|
+
try:
|
|
27
|
+
v = TfidfVectorizer(stop_words="english", token_pattern=r"[A-Za-z][A-Za-z]{2,}",
|
|
28
|
+
max_features=4000)
|
|
29
|
+
X = v.fit_transform(texts)
|
|
30
|
+
scores = np.asarray(X.mean(axis=0)).ravel()
|
|
31
|
+
vocab = np.array(v.get_feature_names_out())
|
|
32
|
+
return ", ".join(vocab[scores.argsort()[::-1][:k]])
|
|
33
|
+
except ValueError:
|
|
34
|
+
return "(not enough text)"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def kmeans(sub: np.ndarray, k: int):
|
|
38
|
+
from sklearn.cluster import KMeans
|
|
39
|
+
k = max(2, min(k, max(2, len(sub) // 15)))
|
|
40
|
+
km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(sub)
|
|
41
|
+
return km.labels_, k
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_frame(entries: list[DiaryEntry], lenses: Lenses, embed,
|
|
45
|
+
entry_embeddings: np.ndarray | None = None):
|
|
46
|
+
"""Return (df, E): a pandas frame of entries scored against the lenses, plus
|
|
47
|
+
the embedding matrix. `embed` is any callable list[str] -> np.ndarray."""
|
|
48
|
+
import pandas as pd
|
|
49
|
+
df = pd.DataFrame([{"date": e.entry_date, "text": e.text} for e in entries])
|
|
50
|
+
df["dt"] = pd.to_datetime(df["date"], errors="coerce")
|
|
51
|
+
E = entry_embeddings if entry_embeddings is not None else embed(df["text"].tolist())
|
|
52
|
+
|
|
53
|
+
for name, phrase in lenses.aspects.items():
|
|
54
|
+
df["asp_" + name] = E @ embed([phrase])[0]
|
|
55
|
+
df["top_aspect"] = df[["asp_" + a for a in lenses.aspects]].idxmax(axis=1).str.replace(
|
|
56
|
+
"asp_", "", regex=False)
|
|
57
|
+
|
|
58
|
+
pos = np.stack([E @ embed([p])[0] for p in lenses.emo_pos], 1).mean(1)
|
|
59
|
+
neg = np.stack([E @ embed([p])[0] for p in lenses.emo_neg], 1).mean(1)
|
|
60
|
+
df["valence"] = pos - neg
|
|
61
|
+
return df, E
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def extract_entities(texts, name_stop, min_count: int = 8, limit: int = 20):
|
|
65
|
+
counts: dict[str, int] = {}
|
|
66
|
+
for t in texts:
|
|
67
|
+
for tok in re.findall(r"\b[A-Z][a-z]+\b", t or ""):
|
|
68
|
+
if tok not in name_stop:
|
|
69
|
+
counts[tok] = counts.get(tok, 0) + 1
|
|
70
|
+
return [{"name": k, "count": v}
|
|
71
|
+
for k, v in sorted(counts.items(), key=lambda x: -x[1]) if v >= min_count][:limit]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def turning_points(valid_df, top_n: int = 6):
|
|
75
|
+
"""Months with the biggest valence swings. Returns [(period_ts, zscore)]."""
|
|
76
|
+
md = valid_df.copy().set_index("dt").sort_index()
|
|
77
|
+
monthly = md["valence"].resample("MS").mean().dropna()
|
|
78
|
+
if len(monthly) < 6:
|
|
79
|
+
return [], monthly
|
|
80
|
+
chg = monthly.diff()
|
|
81
|
+
z = (chg - chg.mean()) / (chg.std() or 1)
|
|
82
|
+
picks = sorted(z.abs().sort_values(ascending=False).head(top_n).index)
|
|
83
|
+
return [(t, float(z.loc[t])) for t in picks], monthly
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _valid_mask(df, min_year, max_year):
|
|
87
|
+
if min_year is None and max_year is None:
|
|
88
|
+
return df["dt"].notna()
|
|
89
|
+
yr = df["dt"].dt.year
|
|
90
|
+
lo = min_year if min_year is not None else -(10 ** 9)
|
|
91
|
+
hi = max_year if max_year is not None else 10 ** 9
|
|
92
|
+
return df["dt"].notna() & yr.between(lo, hi)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def scenes_by_aspect(entries: list[DiaryEntry], embed, lenses: Lenses | None = None,
|
|
96
|
+
per_aspect: int = 15, min_year: int | None = None,
|
|
97
|
+
max_year: int | None = None, entry_embeddings=None) -> dict[str, list[Scene]]:
|
|
98
|
+
"""For each aspect, the entries that read most strongly as it โ candidate
|
|
99
|
+
scenes, ranked. Ported from memoir_scenes.py: rank, don't cluster, because
|
|
100
|
+
logistics-style daily logs are too similar to cluster well."""
|
|
101
|
+
import pandas as pd
|
|
102
|
+
lenses = lenses or Lenses.default()
|
|
103
|
+
df, _ = build_frame(entries, lenses, embed, entry_embeddings)
|
|
104
|
+
valid_idx = df.index[_valid_mask(df, min_year, max_year)].to_numpy()
|
|
105
|
+
result: dict[str, list[Scene]] = {}
|
|
106
|
+
for a in lenses.aspects:
|
|
107
|
+
if not len(valid_idx):
|
|
108
|
+
result[a] = []
|
|
109
|
+
continue
|
|
110
|
+
s = df.loc[valid_idx, "asp_" + a].to_numpy()
|
|
111
|
+
order = valid_idx[s.argsort()[::-1][:per_aspect]]
|
|
112
|
+
scenes = []
|
|
113
|
+
for i in order:
|
|
114
|
+
d = df.at[i, "dt"]
|
|
115
|
+
when = d.date().isoformat() if pd.notna(d) else None
|
|
116
|
+
scenes.append(Scene(
|
|
117
|
+
title=f"{a} โ {when or '?'}",
|
|
118
|
+
summary=snippet(df.at[i, "text"]),
|
|
119
|
+
entries=[entries[i]] if i < len(entries) else [],
|
|
120
|
+
when=when,
|
|
121
|
+
meta={"aspect": a, "score": float(df.at[i, "asp_" + a])},
|
|
122
|
+
))
|
|
123
|
+
result[a] = scenes
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def scenes_report(entries: list[DiaryEntry], embed, lenses: Lenses | None = None,
|
|
128
|
+
per_aspect: int = 15, min_year: int | None = None,
|
|
129
|
+
max_year: int | None = None, entry_embeddings=None) -> str:
|
|
130
|
+
"""Markdown shortlist of candidate scenes by aspect, plus the aspect mix,
|
|
131
|
+
turning points, and out-of-range dates. Ported from memoir_scenes.py."""
|
|
132
|
+
import pandas as pd
|
|
133
|
+
lenses = lenses or Lenses.default()
|
|
134
|
+
df, _ = build_frame(entries, lenses, embed, entry_embeddings)
|
|
135
|
+
df["valid"] = _valid_mask(df, min_year, max_year)
|
|
136
|
+
out_of_range = df[df["dt"].notna() & ~df["valid"]]
|
|
137
|
+
|
|
138
|
+
out = ["# Memoir scenes โ candidate material by emotional aspect", "",
|
|
139
|
+
f"{len(df)} entries ยท {int(df['valid'].sum())} in range. Each section lists the "
|
|
140
|
+
"entries that read most strongly as that feeling โ your shortlist of scenes, "
|
|
141
|
+
"not errand-logs.", "",
|
|
142
|
+
"## Aspect mix (each in-range entry's dominant feeling)", "",
|
|
143
|
+
"| aspect | entries |", "|---|---|"]
|
|
144
|
+
for a, n in df[df["valid"]]["top_aspect"].value_counts().items():
|
|
145
|
+
out.append(f"| {a} | {int(n)} |")
|
|
146
|
+
out.append("")
|
|
147
|
+
|
|
148
|
+
by_aspect = scenes_by_aspect(entries, embed, lenses, per_aspect, min_year, max_year,
|
|
149
|
+
entry_embeddings=None)
|
|
150
|
+
for a, scenes in by_aspect.items():
|
|
151
|
+
out += [f"## {a.upper()} โ top {per_aspect} candidate scenes", ""]
|
|
152
|
+
for sc in scenes:
|
|
153
|
+
out.append(f"- *{sc.when or '?'}* (score {sc.meta['score']:.2f}): {sc.summary}")
|
|
154
|
+
out.append("")
|
|
155
|
+
|
|
156
|
+
tps, _ = turning_points(df[df["valid"]])
|
|
157
|
+
if tps:
|
|
158
|
+
mi = df[df["valid"]].set_index("dt").sort_index()
|
|
159
|
+
out += ["## Candidate turning points (biggest month-to-month swings)", ""]
|
|
160
|
+
for t, z in tps:
|
|
161
|
+
near = mi[(mi.index.year == t.year) & (mi.index.month == t.month)]
|
|
162
|
+
out.append(f"**{t.strftime('%B %Y')}** (z={z:+.2f})")
|
|
163
|
+
for i in range(min(2, len(near))):
|
|
164
|
+
out.append(f" - {snippet(near.iloc[i]['text'])}")
|
|
165
|
+
out.append("")
|
|
166
|
+
|
|
167
|
+
if len(out_of_range):
|
|
168
|
+
out += ["## Entries with out-of-range dates (likely parser typos to fix)", ""]
|
|
169
|
+
for _, r in out_of_range.iterrows():
|
|
170
|
+
out.append(f"- parsed as {r['dt'].date().isoformat()}: {snippet(r['text'], 160)}")
|
|
171
|
+
out.append("")
|
|
172
|
+
return "\n".join(out)
|
monograph/arc/spine.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Propose candidate memoir spines from an Evidence pack, via any LLM. Ported
|
|
2
|
+
from spine_proposer.py: the system prompt and evidence formatting are already
|
|
3
|
+
domain-agnostic; only the Ollama call was specific, and that now lives behind the
|
|
4
|
+
LLM interface.
|
|
5
|
+
|
|
6
|
+
Each spine is one possible book โ a throughline question, the arc it implies, the
|
|
7
|
+
scenes it draws on, and (crucially) what the journal is *missing* that the author
|
|
8
|
+
would have to write from memory.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ..types import Evidence, Spine
|
|
13
|
+
from ..llm import LLM
|
|
14
|
+
from ..llm.ollama_backend import parse_json
|
|
15
|
+
|
|
16
|
+
SYSTEM = (
|
|
17
|
+
"You are a literary memoir editor doing structural reconnaissance on a writer's "
|
|
18
|
+
"multi-year personal journal. You are given only aggregate signals and a capped "
|
|
19
|
+
"set of representative entry snippets โ never the whole journal. "
|
|
20
|
+
"Propose 3 to 6 candidate SPINES the journal could become: distinct memoirs, each "
|
|
21
|
+
"organized around one transformation or question.\n"
|
|
22
|
+
"Hard rules:\n"
|
|
23
|
+
"- Ground every spine ONLY in the evidence provided. Do not invent events, people, "
|
|
24
|
+
"or feelings.\n"
|
|
25
|
+
"- A diary records logistics and the present; it lacks interiority, retrospection, "
|
|
26
|
+
"and anything before it starts. For EACH spine, state plainly in "
|
|
27
|
+
"'missing_to_author' what is NOT in the journal and would have to be written from "
|
|
28
|
+
"memory (e.g. childhood, how the writer was parented, what a moment felt like "
|
|
29
|
+
"inside).\n"
|
|
30
|
+
"- 'throughline' must be a QUESTION the reader follows.\n"
|
|
31
|
+
"- 'scene_ids' must be chosen only from the provided snippet ids.\n"
|
|
32
|
+
"Return ONLY JSON of the form: {\"spines\": [ {\"id\": str, \"title\": str, "
|
|
33
|
+
"\"throughline\": str, \"arc\": str, \"draws_on\": {\"aspects\": [str], "
|
|
34
|
+
"\"people\": [str], \"period\": str}, \"scene_ids\": [int], \"strengths\": str, "
|
|
35
|
+
"\"missing_to_author\": str} ] }"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_user(ev: Evidence) -> str:
|
|
40
|
+
lines = ["EVIDENCE PACK", "", f"{ev.n_valid} dated entries in range.", "",
|
|
41
|
+
"Aspect lenses (share of entries where this is the dominant feeling, and "
|
|
42
|
+
"how its prevalence trends early->late):"]
|
|
43
|
+
for a in ev.aspects:
|
|
44
|
+
lines.append(f"- {a['name']}: {a['share_pct']}% ({a['trend']}); example ids {a['top_ids']}")
|
|
45
|
+
lines += ["", "Entries per year: " + ", ".join(f"{y}:{c}" for y, c in ev.timeline.items())]
|
|
46
|
+
lines += ["", "Biggest emotional swings (candidate turning points):"]
|
|
47
|
+
for t in ev.turning_points:
|
|
48
|
+
lines.append(f"- {t['month']} (z={t['z']}); ids {t['ids']}")
|
|
49
|
+
lines += ["", "Recurring people: " + ", ".join(f"{e['name']}({e['count']})" for e in ev.entities)]
|
|
50
|
+
lines += ["", "REPRESENTATIVE SNIPPETS (cite these ids in scene_ids):"]
|
|
51
|
+
for i, text in ev.snippets.items():
|
|
52
|
+
lines.append(f"[{i}] {text}")
|
|
53
|
+
lines += ["", "Now propose the candidate spines as JSON."]
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def propose_spines(evidence: Evidence, llm: LLM, temperature: float = 0.4) -> list[Spine]:
|
|
58
|
+
"""Ask the LLM for candidate spines and return them as Spine objects. The raw
|
|
59
|
+
fields (throughline, draws_on, scene_ids, strengths, missing_to_author) are
|
|
60
|
+
kept in Spine.meta, with resolved snippet texts for the chosen scene_ids."""
|
|
61
|
+
content = llm.complete(_build_user(evidence), system=SYSTEM,
|
|
62
|
+
temperature=temperature, json_mode=True)
|
|
63
|
+
result = parse_json(content)
|
|
64
|
+
raw = result.get("spines", result if isinstance(result, list) else [])
|
|
65
|
+
spines = []
|
|
66
|
+
for sp in raw:
|
|
67
|
+
scene_ids = sp.get("scene_ids", [])
|
|
68
|
+
scene_texts = {int(i): evidence.snippets.get(int(i))
|
|
69
|
+
for i in scene_ids if str(i).lstrip("-").isdigit()}
|
|
70
|
+
spines.append(Spine(
|
|
71
|
+
title=sp.get("title", "(untitled)"),
|
|
72
|
+
premise=sp.get("throughline", ""),
|
|
73
|
+
arc=sp.get("arc", ""),
|
|
74
|
+
scenes=[],
|
|
75
|
+
meta={"id": sp.get("id"), "draws_on": sp.get("draws_on", {}),
|
|
76
|
+
"scene_ids": scene_ids, "scene_texts": scene_texts,
|
|
77
|
+
"strengths": sp.get("strengths", ""),
|
|
78
|
+
"missing_to_author": sp.get("missing_to_author", "")},
|
|
79
|
+
))
|
|
80
|
+
return spines
|