From 28ca3ac928a8c8e626e33bbe26991325df63a613 Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sat, 11 Apr 2026 18:20:50 -0400 Subject: [PATCH] v2 chunk 1: scaffold note formatter (Path B parallel artifact) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Begin v2 (in-text citation note form) as a parallel artifact to the v1 bibliography formatter, per the Path B architectural decision: no shared mutable state, no shared prompt content, no v1 changes. New artifacts: - src/cmos/parser.py: add find_notes() / NoteDefinition / NotesParseResult as siblings to split_bibliography(). Targets pandoc-style markdown footnote definitions [^marker]: text. Single-line only; multi-line continuation deferred. - src/cmos/note_formatter.py: new module mirroring formatter.py. 17-rule SYSTEM_PROMPT_NOTES for CMOS 18 first-occurrence note form. Reuses cmos.runtime_validator.validate() unchanged — its structural checks all apply to note form too. Caller injection, retry loop, and model selection mirror v1. - tests/test_parser.py: 7 new tests for find_notes(). - tests/test_note_formatter.py: 11 new tests mirroring v1 test discipline (no API calls, fake-caller injection, retry semantics, prompt smoke checks). - exemplars/notes/: new subdirectory with 3 hand-synthesized first-occurrence note exemplars (book, journal article, chapter in edited book). Uses expected_note as the field name (not v1's expected_bibliography). harness/score.py:load_exemplars uses a non-recursive glob, so the v1 canary loader does not see these — Path B isolation is automatic. v1 untouched. v1 canary still loads exactly 17 exemplars. Full test suite: 96 v1 + 18 new v2 = 114 passing. Smoke-tested all 3 v2 exemplars against real GPT-5 (not fakes), 2 runs each. book_first_yu and journal_first_kwon: 4/4 byte-perfect on the first try. chapter_first_doyle: 0/2, surfacing two known prompt gaps for the next iteration: 1. Publisher abbreviation not expanded ("U of Chicago Press" preserved instead of "University of Chicago Press"). v1's formatter.py rule 14 is missing from the v2 prompt. 2. "ed." pluralized to "eds." for multiple editors. CMOS NB uses "ed." invariantly regardless of editor count. Both gaps are addressable prompt edits, not architectural problems — exactly the kind of finding the dev loop is designed to surface. Out of scope (deferred to chunk 2 and later): CLI extension, shortened-form generation, document reassembly, harness scoring loop integration, v2 linter rules, real-draft testing. --- exemplars/notes/book_first_yu.toml | 34 +++ exemplars/notes/chapter_first_doyle.toml | 32 +++ exemplars/notes/journal_first_kwon.toml | 34 +++ src/cmos/note_formatter.py | 250 +++++++++++++++++++++++ src/cmos/parser.py | 63 +++++- tests/test_note_formatter.py | 117 +++++++++++ tests/test_parser.py | 87 +++++++- 7 files changed, 614 insertions(+), 3 deletions(-) create mode 100644 exemplars/notes/book_first_yu.toml create mode 100644 exemplars/notes/chapter_first_doyle.toml create mode 100644 exemplars/notes/journal_first_kwon.toml create mode 100644 src/cmos/note_formatter.py create mode 100644 tests/test_note_formatter.py diff --git a/exemplars/notes/book_first_yu.toml b/exemplars/notes/book_first_yu.toml new file mode 100644 index 0000000..c55f123 --- /dev/null +++ b/exemplars/notes/book_first_yu.toml @@ -0,0 +1,34 @@ +# First-occurrence full note for a single-author book. +# +# Mirrors the v1 exemplar `book_single_author_yu.toml` (same source, same +# author, same publisher) but expects CMOS 18 NOTE form rather than +# bibliography form. Cited passage is on a fictional specific page (45) +# to exercise the rule that note form takes a specific page, not a +# range. +# +# Exercises: +# - Author in NORMAL order ("Charles Yu", not "Yu, Charles") +# - Comma separation between elements (vs periods in bibliography) +# - Publisher in PARENTHESES with year +# - No place of publication (CMOS 14.30, 18th ed.) +# - Specific page number +# - Italicized book title +# - Terminal period at end of note +# +# Field naming note: this exemplar uses `expected_note` (not v1's +# `expected_bibliography`). The v2 scoring harness loader is not yet +# wired up; chunk 1 of v2 only smoke-tests these via direct tomllib +# loading. The field name change is forward-compatible. +source = "Real published book; this is a fabricated specific page citation for testing note form" +type = "note_book" +tags = ["note", "book", "single-author", "first-occurrence"] +canary = true +messy_input = "yu, charles. interior chinatown. New York: Pantheon Books, 2020. p. 45." +expected_note = "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + +[canonical] +author = "Charles Yu" +title = "Interior Chinatown" +publisher = "Pantheon Books" +year = 2020 +page = 45 diff --git a/exemplars/notes/chapter_first_doyle.toml b/exemplars/notes/chapter_first_doyle.toml new file mode 100644 index 0000000..9f5e21f --- /dev/null +++ b/exemplars/notes/chapter_first_doyle.toml @@ -0,0 +1,32 @@ +# First-occurrence full note for a chapter in an edited book. +# +# Mirrors the v1 exemplar `book_chapter_doyle.toml` (same chapter, same +# editors, same publisher, same year) but expects CMOS 18 NOTE form +# rather than bibliography form. The cited passage is on a fictional +# specific page (117) within the chapter's full range. +# +# Exercises: +# - Author in NORMAL order ("Kathleen Doyle") +# - Chapter title in straight quotes with closing comma INSIDE the +# quotes ("The Queen Mary Psalter,") +# - Lowercase "in" before the italicized book title (CMOS note form) +# - "ed." abbreviation, NOT "Edited by" +# - Editors in NORMAL order ("P. J. M. Marks and Stephen Parkin") +# - Publisher in PARENTHESES with year +# - Specific page citation +# - Terminal period +source = "Real published chapter; specific cited page is fabricated for note-form testing" +type = "note_chapter" +tags = ["note", "chapter", "edited-volume", "first-occurrence", "two-editors"] +canary = true +messy_input = "Kathleen Doyle, 'the queen mary psalter,' in The Book by Design: The Remarkable Story of the World's Greatest Invention, P. J. M. Marks and Stephen Parkin eds., Chicago: U of Chicago Press, 2023, pp. 114-120, cited p. 117." +expected_note = "Kathleen Doyle, \"The Queen Mary Psalter,\" in *The Book by Design: The Remarkable Story of the World's Greatest Invention*, ed. P. J. M. Marks and Stephen Parkin (University of Chicago Press, 2023), 117." + +[canonical] +author = "Kathleen Doyle" +chapter_title = "The Queen Mary Psalter" +book_title = "The Book by Design: The Remarkable Story of the World's Greatest Invention" +editors = "ed. P. J. M. Marks and Stephen Parkin" +publisher = "University of Chicago Press" +year = 2023 +page = 117 diff --git a/exemplars/notes/journal_first_kwon.toml b/exemplars/notes/journal_first_kwon.toml new file mode 100644 index 0000000..6c907ac --- /dev/null +++ b/exemplars/notes/journal_first_kwon.toml @@ -0,0 +1,34 @@ +# First-occurrence full note for a journal article with DOI. +# +# Mirrors the v1 exemplar `journal_with_doi_kwon.toml` (same article, +# same author, same DOI) but expects CMOS 18 NOTE form rather than +# bibliography form. The cited passage is on a fictional specific +# page within the article's full range — the bibliography form has +# `1818–59`, the note form cites a single page like `1830`. +# +# Exercises: +# - Author in NORMAL order ("Hyeyoung Kwon") +# - Article title in straight quotes with closing comma INSIDE the +# quotes ("Inclusion Work: ...,") +# - Italicized journal name +# - Volume / issue / year format same as bibliography: +# *Journal* VOL, no. ISSUE (YEAR) +# - Specific page after the (YEAR): construct +# - DOI as full https URL +# - Terminal period +source = "Real published article; specific cited page is fabricated for note-form testing" +type = "note_journal" +tags = ["note", "journal", "single-author", "first-occurrence", "doi"] +canary = true +messy_input = "Kwon, Hyeyoung. \"inclusion work: children of immigrants claiming membership in everyday life.\" American Journal of Sociology, Vol. 127, Issue 6, 2022, pp. 1818-1859. DOI: 10.1086/720277. (cited p. 1830)" +expected_note = "Hyeyoung Kwon, \"Inclusion Work: Children of Immigrants Claiming Membership in Everyday Life,\" *American Journal of Sociology* 127, no. 6 (2022): 1830, https://doi.org/10.1086/720277." + +[canonical] +author = "Hyeyoung Kwon" +article_title = "Inclusion Work: Children of Immigrants Claiming Membership in Everyday Life" +journal = "American Journal of Sociology" +volume = 127 +issue = 6 +year = 2022 +page = 1830 +doi = "https://doi.org/10.1086/720277" diff --git a/src/cmos/note_formatter.py b/src/cmos/note_formatter.py new file mode 100644 index 0000000..0e378c1 --- /dev/null +++ b/src/cmos/note_formatter.py @@ -0,0 +1,250 @@ +"""Note formatter — the v2 inner-loop artifact for CMOS 18 NB note form. + +Path B sibling of ``cmos.formatter``: a separate prompt, separate exemplar +corpus, separate inner-loop iteration target. The two artifacts share NO +mutable state and NO prompt content. v1 ``format_bibliography_entry`` +remains untouched as v2 evolves. + +Some boilerplate (OpenAI client setup, reasoning-model detection, retry +loop) is intentionally duplicated rather than imported from +``cmos.formatter``. The duplication is small (~30 lines) and the +independence is more valuable than DRY here — if the two formatters ever +need different models, retry counts, or callers, the change is local. + +Caller injection: ``format_note_entry`` accepts a ``caller`` shim so tests +and the harness can substitute a fake for the OpenAI call without touching +the network. The default caller reads ``OPENAI_API_KEY`` (via +python-dotenv) and calls the configured model with ``temperature=0`` for +non-reasoning models. + +Scope of v2 iteration 1 (this commit): FIRST-OCCURRENCE FULL NOTE form +only. Shortened subsequent-citation form is deferred — that requires +state across notes which the formatter does not currently track. The +intended pipeline is: format every note as full form, then have a +separate deterministic Python pass identify repeated sources and +generate shortened forms from the full forms. That second pass is not +yet implemented. +""" + +from __future__ import annotations + +import os +from typing import Callable + +from dotenv import load_dotenv + +from cmos.runtime_validator import validate + +# Default model. Override with OPENAI_MODEL=... in your .env. +MODEL = os.environ.get("OPENAI_MODEL", "gpt-5") + +# How many extra attempts after the first if the runtime validator rejects +# the candidate. Mirrors v1 formatter behavior — see cmos.formatter and +# the project memory on GPT-5 nondeterminism. +DEFAULT_MAX_RETRIES = 2 + +# SYSTEM_PROMPT_NOTES: the v2 note formatter's program. Iterated by the +# autoresearch loop independently of v1's SYSTEM_PROMPT. Keep it explicit, +# versioned via git history, and traceable to CMOS 18 sources. +SYSTEM_PROMPT_NOTES = """\ +You are a careful Chicago Manual of Style (CMOS), 18th edition, +notes-and-bibliography NOTE formatter. You produce CMOS 18 NOTE form +(not bibliography form). + +Your task: given a single messy citation entry in English (typically +the body of a markdown footnote definition), rewrite it in CMOS 18 +FIRST-OCCURRENCE FULL NOTE form. Output the formatted note text and +nothing else — no commentary, no leading number ("1.", "2."), no code +fences, no surrounding quotes. + +Rules you must follow (CMOS 18th edition specifically, NOTE form): + +1. AUTHOR NAMES are in NORMAL order ("First Last"), NOT inverted. + - One author: "Charles Yu" + - Two authors: "Charles Yu and Hyeyoung Kwon" + - Three authors: "Charles Yu, Hyeyoung Kwon, and Kathleen Doyle" + - Four or more authors: first author followed by ", et al." (CMOS + 14.76: notes use first author + et al. for 4+ authors, even + though bibliography lists more before truncating). + Corporate authors (e.g., "Google", "Modern Language Association") + appear as-is, not reordered. + Non-Western names already in family-first order (e.g., "Liu Xinwu", + "Murakami Haruki") stay as-is — do not Western-order them. + +2. ELEMENT SEPARATION uses COMMAS, not periods. A note is one + sentence-like construction terminated by a single period at the + very end. Periods are NOT used between author / title / journal + / publisher in note form. This is the most visible difference + from bibliography form. + +3. ITALIC AND QUOTE CONVENTIONS are the same as bibliography form: + book titles and journal titles are italicized using Markdown + asterisks (*Title*); article titles and chapter titles are + wrapped in straight double quotes. In NOTE form the closing + comma goes INSIDE the closing quote of the article/chapter + title: "Article Title," (not "Article Title",). + +4. BOOK NOTE FORM: + Author, *Book Title* (Publisher, Year), specific-page. + The publisher and year are in PARENTHESES, separated by a comma. + Place of publication is NOT included (CMOS 14.30, 18th ed.). + +5. JOURNAL ARTICLE NOTE FORM: + Author, "Article Title," *Journal Title* VOL, no. ISSUE (YEAR): + specific-page, https://doi.org/10.xxxx/yyyy. + Note that the closing comma after the article title is INSIDE + the quotes, and the colon between (YEAR) and the page is NOT + preceded by a space. + +6. CHAPTER IN EDITED BOOK NOTE FORM: + Author, "Chapter Title," in *Book Title*, ed. Editor Names + (Publisher, Year), specific-page. + Use "ed." (abbreviated), NOT "Edited by". The lowercase "in" + before the italicized book title is part of CMOS note form + and should appear as shown. + +7. TRANSLATED WORKS use "trans." (abbreviated), NOT "Translated by". + +8. SPECIFIC PAGE: notes cite the specific page (or page range) of + the passage being referenced, NOT the full page range of the + article. If the source provides only a full article range and + no specific page, use the first page of the range. Do NOT + fabricate a more specific page number than the source provides. + +9. PREFER DOIS over generic URLs. When a DOI is present, format it + as a full URL: https://doi.org/10.xxxx/yyyy (no "doi:" prefix, + no bare DOI). + +10. NO IBID. CMOS 18 deprecates "Ibid." entirely. Do not produce it + under any circumstances, even if the input contains it. If the + input is an "Ibid." reference, this iteration of the formatter + cannot resolve it (shortened-form generation is out of scope). + Return the cleanest possible full-form note from whatever + information the messy input contains. + +11. PRESERVE TERMINAL PUNCTUATION inside titles. If a title ends in + "?" or "!", keep that punctuation inside the closing quote and + add a separating comma OUTSIDE the closing quote in note form. + Example: "Are Flax Seeds All That?," *New York Times*, ... + Do NOT silently replace title-internal "?" or "!" with a comma + or period. + +12. HEADLINE-STYLE CAPITALIZATION for titles, with the same CMOS + 8.159 carve-outs as v1 bibliography form: lowercase prepositions + regardless of length (about, above, across, after, against, + among, around, as, at, before, beyond, by, for, from, in, into, + of, on, over, through, to, under, until, up, with, within, etc.) + UNLESS the preposition is the first or last word of the title + or subtitle. SUBORDINATING conjunctions (If, That, Because, + Although, Whether, Unless) are CAPITALIZED. Apply this rule + INDEPENDENTLY of the source's casing. + +13. PRESERVE DELIBERATE LOWERCASING of proper nouns (the journal + "portal: Libraries and the Academy", authors like "bell hooks", + "danah boyd", "e e cummings", "k.d. lang", brand names like + "iPhone", "eBay"). When in doubt about whether a lowercased form + is intentional, err on the side of preserving the source's form. + +14. PERIODICAL NAMES: drop a leading "The" from any periodical name + (newspaper, magazine, journal). Write "New York Times", not + "The New York Times"; "Library Quarterly", not "The Library + Quarterly". This rule applies ONLY to periodicals — do NOT drop + a leading "The" from a BOOK title or REPORT title. + +15. INCLUSIVE-NUMBER ELISION for any page range you do output (CMOS + 9.61): use the same elision rules as v1 — 1-99 keep both numbers + (3-10, 71-72); 110-199 use two-digit elision (137-49, 1818-59); + across hundreds boundary use full numbers (799-810, 1496-1504). + Always use an en-dash (–), never an ASCII hyphen (-), in number + ranges. (Most notes cite a single page, so this rule fires + rarely, but it applies when needed.) + +16. THE NOTE NUMBER PREFIX ("1. ", "2. ") is NOT part of the + formatted note text. The numbering is supplied externally by + the document or by markdown footnote rendering. DO NOT include + a leading number in your output. + +17. DO NOT invent, fetch, or guess missing metadata. If a field is + missing in the source, leave it missing. Do not fabricate + authors, publishers, years, page numbers, URLs, or DOIs. + +Output ONLY the single reformatted note. No preamble, no explanation, +no leading number, no code fences. +""" + + +Caller = Callable[[str, str], str] + + +def build_user_message(messy_entry: str) -> str: + return ( + "Reformat the following citation entry to CMOS 18 notes-and-bibliography " + "FIRST-OCCURRENCE FULL NOTE form (note form, not bibliography form). " + "Output only the reformatted note text — no leading number.\n\n" + f"{messy_entry}" + ) + + +def _is_reasoning_model(model: str) -> bool: + """Reasoning-model families (GPT-5, o-series) don't accept temperature overrides.""" + prefixes = ("gpt-5", "o1", "o3", "o4") + return any(model.startswith(p) for p in prefixes) + + +def _openai_caller(system: str, user: str) -> str: + """Default caller — hits the real OpenAI API. + + Not exercised in unit tests. Integration tests or real loop runs + use this path and require ``OPENAI_API_KEY`` in the environment or + a .env file at the project root. + """ + load_dotenv() + from openai import OpenAI # imported lazily so unit tests do not need the network + + client = OpenAI() + kwargs: dict = { + "model": MODEL, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + } + if not _is_reasoning_model(MODEL): + kwargs["temperature"] = 0 + response = client.chat.completions.create(**kwargs) + return response.choices[0].message.content or "" + + +def format_note_entry( + messy_entry: str, + caller: Caller | None = None, + max_retries: int = DEFAULT_MAX_RETRIES, +) -> str: + """Reformat a single messy citation to CMOS 18 first-occurrence note form. + + Pass a ``caller`` shim to avoid the real API (used by tests and the + harness when running with a fake formatter for loop smoke tests). + + Reasoning models (gpt-5, o-series) are nondeterministic. The candidate + is checked against ``cmos.runtime_validator`` after each call; if it + fails any structural sanity check (missing terminal period, dropped + italics, stray Ibid., unbalanced quotes/asterisks), the formatter + retries up to ``max_retries`` more times. The runtime validator is + intentionally weaker than a full CMOS rule check because at runtime + there is no Exemplar — only structural rules independent of source + type apply. The validator is reused unchanged from v1 because all + its checks (terminal period, italic balance, no Ibid, balanced + quotes) apply to note form as well. + + If every attempt fails validation, the LAST attempt is returned (we + don't raise — the caller still gets something usable, and the + failure will surface via the v2 scoring linter or human review). + """ + call = caller or _openai_caller + user_message = build_user_message(messy_entry) + last: str = "" + for _ in range(max_retries + 1): + last = call(SYSTEM_PROMPT_NOTES, user_message).strip() + if validate(last).passed: + return last + return last diff --git a/src/cmos/parser.py b/src/cmos/parser.py index e77c9b1..79571bc 100644 --- a/src/cmos/parser.py +++ b/src/cmos/parser.py @@ -5,8 +5,18 @@ section as running until the next level-2 (``##``) heading or end of file, and return each non-blank line as one entry. Bullet markers (``- ``, ``* ``) at the start of a line are stripped. -Out of scope in v1: in-prose citation rewriting, multi-line entries, nested -sections, alternative heading names (e.g., "Works Cited", "References"). +v2 scope (notes): ``find_notes`` locates pandoc-style markdown footnote +definitions ``[^marker]: text`` anywhere in the document. Single-line +definitions only — multi-line continuation (indented continuation lines) +is deferred. The reference markers in prose (the ``[^marker]`` references +themselves, without the trailing colon) are NOT collected; only the +definitions need reformatting. + +Out of scope in v1: in-prose citation rewriting, multi-line entries, +nested sections, alternative heading names (e.g., "Works Cited", +"References"). Out of scope in v2 (so far): multi-line note definitions, +shortened-form generation, reassembly of formatted notes back into the +document. """ from __future__ import annotations @@ -26,9 +36,31 @@ class ParseResult: after: str +@dataclass +class NoteDefinition: + """A single pandoc-style markdown footnote definition. + + ``marker`` is the identifier between ``[^`` and ``]:`` (e.g., ``"1"`` + or ``"smith2020"``). ``text`` is the note body with surrounding + whitespace stripped. ``line_number`` is the 0-indexed line in the + source document where the definition appeared — currently unused but + captured for future reassembly support. + """ + + marker: str + text: str + line_number: int + + +@dataclass +class NotesParseResult: + definitions: list[NoteDefinition] + + _HEADING_RE = re.compile(r"^##\s+bibliography\s*$", re.IGNORECASE) _LEVEL_TWO_RE = re.compile(r"^##\s+\S") _BULLET_RE = re.compile(r"^[-*]\s+") +_NOTE_DEF_RE = re.compile(r"^\[\^([^\]]+)\]:\s*(.*?)\s*$") def split_bibliography(text: str) -> ParseResult: @@ -64,3 +96,30 @@ def split_bibliography(text: str) -> ParseResult: entries.append(stripped) return ParseResult(before=before, entries=entries, after=after) + + +def find_notes(text: str) -> NotesParseResult: + """Find pandoc-style markdown footnote definitions in ``text``. + + Targets the most common form: ``[^marker]: note text`` on a single + line. The marker can be numeric (``1``) or named (``smith2020``). + Multi-line definitions (where the note body continues on indented + subsequent lines) are out of scope for this iteration; only the + first line is captured. + + Returns a ``NotesParseResult`` with definitions in the order they + appear in the document. If the document contains no footnote + definitions, returns an empty list rather than raising — a draft + with no notes is a valid (if uninteresting) input. + """ + definitions: list[NoteDefinition] = [] + for line_number, line in enumerate(text.splitlines()): + match = _NOTE_DEF_RE.match(line) + if match is None: + continue + marker = match.group(1) + body = match.group(2) + definitions.append( + NoteDefinition(marker=marker, text=body, line_number=line_number) + ) + return NotesParseResult(definitions=definitions) diff --git a/tests/test_note_formatter.py b/tests/test_note_formatter.py new file mode 100644 index 0000000..a985e86 --- /dev/null +++ b/tests/test_note_formatter.py @@ -0,0 +1,117 @@ +"""Tests for src/cmos/note_formatter.py — the v2 inner-loop iterable artifact. + +Mirrors tests/test_formatter.py in shape and discipline. The note formatter +is iterated independently of the bibliography formatter (Path B); these +tests pin the stable seams (caller injection, retry loop, prompt smoke +checks) and leave LLM output shape testing to the v2 exemplar canary. + +No real API calls in this file. Tests use injected fake callers. +""" + +from cmos.note_formatter import ( + MODEL, + SYSTEM_PROMPT_NOTES, + build_user_message, + format_note_entry, +) + + +def test_default_model_is_gpt5(): + # Same default as v1. Override with OPENAI_MODEL=... in .env. + assert MODEL == "gpt-5" + + +def test_system_prompt_mentions_cmos_18_note_form(): + assert "CMOS" in SYSTEM_PROMPT_NOTES + assert "18" in SYSTEM_PROMPT_NOTES + assert "NOTE" in SYSTEM_PROMPT_NOTES + + +def test_system_prompt_mentions_no_inversion(): + # The single most distinctive difference from bibliography form: notes + # use normal-order author names ("First Last"), not inverted. + lower = SYSTEM_PROMPT_NOTES.lower() + assert "normal" in lower or "not inverted" in lower + + +def test_system_prompt_mentions_comma_separation(): + # The second most distinctive difference: commas between elements, + # not periods. + lower = SYSTEM_PROMPT_NOTES.lower() + assert "comma" in lower + + +def test_system_prompt_excludes_leading_number(): + # The note number prefix ("1. ", "2. ") is supplied externally; the + # formatter must not include it. The prompt must say so. + lower = SYSTEM_PROMPT_NOTES.lower() + assert "leading number" in lower or "do not include" in lower + + +def test_system_prompt_mentions_no_ibid(): + # CMOS 18 deprecates Ibid., same as v1. + assert "Ibid" in SYSTEM_PROMPT_NOTES or "ibid" in SYSTEM_PROMPT_NOTES.lower() + + +def test_build_user_message_contains_the_messy_input(): + msg = build_user_message("yu, charles. interior chinatown. 2020, 45") + assert "yu, charles. interior chinatown. 2020, 45" in msg + + +def test_note_formatter_uses_injected_caller(): + """Same caller-injection seam as v1. Tests can substitute a fake without + touching the network.""" + recorded: dict = {} + + def fake_caller(system: str, user: str) -> str: + recorded["system"] = system + recorded["user"] = user + return "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + + output = format_note_entry( + "yu, charles. interior chinatown. New York: Pantheon Books, 2020, p 45.", + caller=fake_caller, + ) + assert output == "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + assert recorded["system"] == SYSTEM_PROMPT_NOTES + assert "yu, charles" in recorded["user"] + + +def test_note_formatter_strips_whitespace_from_caller_output(): + def fake_caller(system: str, user: str) -> str: + return " Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45. \n" + + output = format_note_entry("anything", caller=fake_caller) + assert output == "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + + +def test_note_formatter_retries_on_validator_failure(): + """If the runtime validator rejects the first attempt (e.g., italics + dropped), the formatter retries. Same retry semantics as v1.""" + attempts = [] + + def flaky_caller(system: str, user: str) -> str: + attempts.append(len(attempts) + 1) + if len(attempts) == 1: + # First attempt: italics dropped — validator should fail. + return "Charles Yu, Interior Chinatown (Pantheon Books, 2020), 45." + return "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + + output = format_note_entry("messy yu", caller=flaky_caller) + assert "*Interior Chinatown*" in output + assert len(attempts) == 2 + + +def test_note_formatter_returns_last_attempt_after_max_retries(): + """If every attempt fails validation, return the last attempt and don't + loop forever. Same as v1 — the failure surfaces via scoring or human + review, not via a runtime exception.""" + attempts = [] + + def always_bad(system: str, user: str) -> str: + attempts.append(1) + return "Charles Yu, Interior Chinatown (Pantheon Books, 2020), 45." + + output = format_note_entry("messy", caller=always_bad, max_retries=2) + assert len(attempts) == 3 # 1 initial + 2 retries + assert "*" not in output diff --git a/tests/test_parser.py b/tests/test_parser.py index 508464d..c6e1d5f 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -4,11 +4,15 @@ v1 scope: locate a ``## Bibliography`` heading (case-insensitive) and return the prose before it, the list of entries inside it, and the prose after it. The section ends at the next ``##`` (level-2) heading or end of file. Blank lines and bullet markers (``- `` or ``* ``) are stripped from entries. + +v2 (notes) parser scope: find pandoc-style markdown footnote definitions +``[^marker]: text`` anywhere in the document. Single-line definitions only; +multi-line continuation is deferred to a later chunk. """ import pytest -from cmos.parser import NoBibliographyError, split_bibliography +from cmos.parser import NoBibliographyError, find_notes, split_bibliography def test_three_entries_under_bibliography_heading(): @@ -74,3 +78,84 @@ Only entry. def test_no_bibliography_raises(): with pytest.raises(NoBibliographyError): split_bibliography("## Introduction\n\nJust prose.\n") + + +# --- find_notes (v2 parser, pandoc-style markdown footnote definitions) ---- + + +def test_find_notes_single_definition(): + draft = """\ +Some prose with a citation.[^1] + +[^1]: Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45. +""" + result = find_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].marker == "1" + assert result.definitions[0].text == "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + + +def test_find_notes_multiple_definitions_in_order(): + draft = """\ +Prose.[^1] More prose.[^2] And again.[^3] + +[^1]: First note. +[^2]: Second note. +[^3]: Third note. +""" + result = find_notes(draft) + assert len(result.definitions) == 3 + assert [d.marker for d in result.definitions] == ["1", "2", "3"] + assert [d.text for d in result.definitions] == [ + "First note.", + "Second note.", + "Third note.", + ] + + +def test_find_notes_named_marker(): + draft = """\ +Citation.[^smith2020] + +[^smith2020]: Smith, J. (2020). Some work. +""" + result = find_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].marker == "smith2020" + assert result.definitions[0].text == "Smith, J. (2020). Some work." + + +def test_find_notes_returns_empty_when_no_definitions(): + draft = "Just prose with no footnote definitions.\n" + result = find_notes(draft) + assert result.definitions == [] + + +def test_find_notes_records_line_number(): + draft = """\ +Line zero. +Line one. + +[^1]: Note on line three. +""" + result = find_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].line_number == 3 + + +def test_find_notes_strips_trailing_whitespace_from_text(): + draft = "[^1]: Note text with trailing spaces. \n" + result = find_notes(draft) + assert result.definitions[0].text == "Note text with trailing spaces." + + +def test_find_notes_definition_can_appear_before_reference(): + # Pandoc allows definitions anywhere in the doc, not just at the bottom. + draft = """\ +[^1]: A definition before the reference. + +Some prose that cites it.[^1] +""" + result = find_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].text == "A definition before the reference."