File size: 8,260 Bytes
55d0eca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
"""The evidence a themed report section is written from. Plain code, no model.

A section is about one or more themes (report_themes.py) and its depth (report_blueprint.py) says how much room it has. What
the writer gets, from the posts that have ideas in those themes:

  - the posts that say the most about them (most of the section's themes first, then most ideas) as the writer wrote them: numbered
    lines taken from around the lines the ideas came from, with the start of the post for context; a short post that is mostly about
    the theme goes in whole. Several posts, each with an equal share, so a section blends posts and does not walk through one;
  - every other post in the themes as the short summaries already stored for it (label, note, points, and the lines to cite);
  - nothing raw for a brief section: summaries only.

Nothing is cited in the report, so the text carries no line numbers and no paths: a post is named by its title."""

import logging
import math
import uuid
from dataclasses import dataclass, field

from app.corpus import Corpus
from app.models import Document
from app.pipeline.passages import _body_start
from app.pipeline.report_blueprint import Section
from app.pipeline.report_themes import Idea, Theme

BUDGETS = {"deep": 32_000, "standard": 16_000, "brief": 4_000}  # characters of evidence (about 8k, 4k and 1k tokens)
RAW_POSTS = {"deep": 5, "standard": 3, "brief": 0}  # posts read as written, at most
RAW_SHARE = 0.75  # of the budget when other posts are summarised too
WHOLE_POST_CHARS = 10_000  # a post this short, mostly about the theme, goes in whole (about 2,500 tokens)
WHOLE_POST_SHARE = 0.5
MARGIN = 6  # lines kept around the lines an idea came from
LEAD_LINES = 6  # lines kept from the start of a post, for context
JOIN_GAP = 2  # windows this close are one
SUMMARY_DETAILS = 2

log = logging.getLogger(__name__)


@dataclass
class Evidence:
    blocks: list[str] = field(default_factory=list)
    posts: list[str] = field(default_factory=list)  # paths of every post used
    raw_posts: int = 0  # how many of them were read as written


def _merge(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
    out: list[tuple[int, int]] = []
    for a, b in sorted(ranges):
        if out and a <= out[-1][1] + JOIN_GAP:
            out[-1] = (out[-1][0], max(out[-1][1], b))
        else:
            out.append((a, b))
    return out


def _text(lines: list[str], a: int, b: int) -> list[str]:
    return [lines[n - 1] for n in range(a, min(b, len(lines)) + 1) if lines[n - 1].strip()]


def _idea_ranges(d: Document, ideas: list[Idea], total: int, first: int) -> list[tuple[int, int]]:
    out = []
    for idea in ideas:
        for s in idea.sources:
            try:
                a, b = int(s["line_start"]), int(s["line_end"])
            except (KeyError, TypeError, ValueError):
                continue
            if s.get("path") == d.path and total:
                out.append((max(first + 1, a - MARGIN), min(total, max(a, b) + MARGIN)))
    return out


def raw_post(corpus: Corpus, d: Document, ideas: list[Idea], names: list[str], cap: int) -> str | None:
    """One post as written: the whole of it when it is short and mostly about the section's themes, else the lead and the windows
    around its ideas' lines, merged, cut to `cap` characters (the windows that fit, in order of the post). None if unreadable."""
    try:
        lines = corpus.read_lines(d.path)
    except Exception:
        log.warning("report evidence: cannot read post %s, its summary stands in", d.path, exc_info=True)
        return None
    first = _body_start(lines)  # lines before this are the file's header
    head = f"POST {d.title} [themes: {', '.join(names)}]"
    stored = len((d.metadata_json or {}).get("ideas") or []) or 1
    if len(d.clean_text or "") <= WHOLE_POST_CHARS and len(ideas) / stored >= WHOLE_POST_SHARE:
        whole = "\n".join(_text(lines, first + 1, len(lines)))
        if len(head) + len(whole) <= cap:
            log.debug("report evidence: whole post %s (%d chars)", d.path, len(whole))
            return f"{head}\n{whole}"
    ranges = _idea_ranges(d, ideas, len(lines), first)
    windows = _merge([(first + 1, min(len(lines), first + LEAD_LINES))] + ranges)
    parts, used = [], len(head)
    for a, b in windows:
        text = "\n".join(_text(lines, a, b))
        if not text:
            continue
        if parts and used + len(text) + 6 > cap:
            break
        parts.append(text[: max(cap - used, 200)] if not parts else text)
        used += len(parts[-1]) + 6
    return f"{head}\n" + "\n[...]\n".join(parts) if parts else None


def _summary_line(idea: Idea, theme_name: str) -> str:
    points = "; ".join(f"{x.get('label', '')}: {x.get('note', '')}".strip(": ") for x in idea.details[:SUMMARY_DETAILS]
                       if isinstance(x, dict))
    return f'- {idea.label}: {idea.note}' + (f" ({points})" if points else "") + f' (from "{idea.doc.title}", {theme_name})'


def build(corpus: Corpus, section: Section, themes: dict[str, Theme], budget: int | None = None) -> Evidence:
    """The evidence for one body or synthesis section: raw posts first, then summaries of the rest, within `budget` characters."""
    budget = budget or BUDGETS.get(section.depth, BUDGETS["standard"])
    items: list[tuple[Theme, Idea]] = [(themes[t], i) for t in section.theme_ids if t in themes for i in themes[t].ideas]
    out = Evidence()
    if not items:
        log.warning("report evidence: section=%r has no ideas (themes %s)", section.heading, section.theme_ids)
        return out
    by_doc: dict[uuid.UUID, list[tuple[Theme, Idea]]] = {}
    for t, i in items:
        by_doc.setdefault(i.doc.id, []).append((t, i))

    def key(entries: list[tuple[Theme, Idea]]) -> tuple:
        d = entries[0][1].doc
        stamp = d.published_at.timestamp() if d.published_at else 0
        return (-len({t.id for t, _ in entries}), -len(entries), -stamp)

    ranked = sorted(by_doc.values(), key=key)
    wanted = RAW_POSTS.get(section.depth, 0)
    raw_budget = (budget if len(ranked) <= wanted else int(budget * RAW_SHARE)) if wanted else 0
    each = raw_budget // max(min(wanted, len(ranked)), 1)
    rest: list[list[tuple[Theme, Idea]]] = []
    for entries in ranked:
        if out.raw_posts < wanted:
            d = entries[0][1].doc
            names = list(dict.fromkeys(t.name for t, _ in entries))
            block = raw_post(corpus, d, [i for _, i in entries], names, each)
            if block is not None:  # an unreadable post is passed over: the next one takes its place, its summary stands in
                out.blocks.append(block)
                out.posts.append(d.path)
                out.raw_posts += 1
                continue
        rest.append(entries)

    summary_budget = max(budget - sum(len(b) for b in out.blocks), 0)
    lists = [[_summary_line(i, t.name) for t, i in entries] for entries in rest]
    lines, used, rank = [], 0, 0
    while any(rank < len(x) for x in lists):
        for x in lists:
            if rank < len(x) and used + len(x[rank]) <= summary_budget:
                lines.append(x[rank])
                used += len(x[rank]) + 1
        rank += 1
        if used >= summary_budget:
            break
    if lines:
        out.blocks.append("SUMMARIES of other posts\n" + "\n".join(lines))
    out.posts += [e[0][1].doc.path for e in rest if e[0][1].doc.path not in out.posts]
    log.info("report evidence: section=%r depth=%s themes=%s ideas=%d posts=%d raw_posts=%d summarised_posts=%d summary_lines=%d "
             "chars=%d budget=%d", section.heading, section.depth, section.theme_ids, len(items), len(by_doc), out.raw_posts,
             len(rest), len(lines), sum(len(b) for b in out.blocks), budget)
    return out


def scale_budgets(sections: list[Section], total: int) -> dict[str, int]:
    """Section heading -> budget, the standard budgets scaled down together when they add up to more than `total`."""
    wanted = {s.heading: BUDGETS.get(s.depth, BUDGETS["standard"]) for s in sections if s.role in ("body", "synthesis")}
    factor = min(1.0, total / max(sum(wanted.values()), 1))
    return {h: max(int(math.floor(b * factor)), 1_000) for h, b in wanted.items()}