Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0269b9e
fix: preserve semantic image evidence tables
seonghobae Aug 20, 2026
3cd2d6e
Merge remote-tracking branch 'origin/feat/buyer-evidence-gap-structur…
seonghobae Aug 20, 2026
e055463
fix: allow deep vision evidence completion
seonghobae Aug 20, 2026
f5b2345
fix: prevent image OCR evidence regression
seonghobae Aug 20, 2026
2702fd6
test: cover Markdown table delimiter edges
seonghobae Aug 20, 2026
fe0a4f2
test: cover escaped Markdown table cells
seonghobae Aug 20, 2026
e5d0221
fix: normalize quoted gateway environment values
seonghobae Aug 20, 2026
b7e6e82
fix: preserve vision work across database restarts
seonghobae Aug 20, 2026
497d9e3
Merge remote-tracking branch 'origin/feat/buyer-evidence-gap-structur…
seonghobae Aug 20, 2026
817681b
Merge remote-tracking branch 'origin/feat/buyer-evidence-gap-structur…
seonghobae Aug 20, 2026
a9b4be2
Merge remote-tracking branch 'origin/feat/buyer-evidence-gap-structur…
seonghobae Aug 21, 2026
7e4cc10
Merge remote-tracking branch 'origin/feat/buyer-evidence-gap-structur…
seonghobae Aug 21, 2026
ba71bc1
fix: preserve colon-containing image OCR
seonghobae Aug 21, 2026
d8e8ede
Merge remote-tracking branch 'origin/feat/buyer-evidence-gap-structur…
seonghobae Aug 21, 2026
b9bba3b
Merge semantic image tables into image evidence
seonghobae Aug 21, 2026
93c8314
Merge remote-tracking branch 'origin/feat/buyer-evidence-gap-structur…
seonghobae Aug 21, 2026
fc61f60
Merge remote-tracking branch 'origin/feat/image-evidence-markdown-sem…
seonghobae Aug 21, 2026
a4a6008
fix: continue backfill after protected OCR retry
seonghobae Aug 21, 2026
1c861f4
fix: preserve multiline vision captions
seonghobae Aug 21, 2026
bdf3716
chore: restack image evidence semantics on current document parent
seonghobae Aug 21, 2026
7727cb1
fix: preserve escaped image table cells
seonghobae Aug 21, 2026
497ac12
feat: preserve metric superscript and subscript semantics (#344)
seonghobae Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ All notable changes to this project are documented here. Format follows
semantic-unit parser and buyer body renderer. See the [product and
technical gap baseline](docs/product-technical-gap-baseline.md) and
[ADR 0103](docs/adr/0103-semantic-document-evidence-contract.md).
- Preserve multiline VISION table rows, render parent and region OCR tables
accessibly, and request source-visible entity, relationship, layout, and
document-purpose evidence instead of a generic image caption. VISION calls
now share the structure channel's 600-second deep-agent runtime boundary;
an empty same-image retry can no longer erase previously observed OCR.

- `make smoke` and `make seed` now run through the locked project `uv`
environment, so local OIDC and synthetic-data workflows resolve the same
Expand Down
4 changes: 3 additions & 1 deletion docker/contextual-orchestrator/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@


def _pop_first_env(*names: str) -> str:
"""Read the first configured alias without leaving credentials in the environment."""
"""Read the first alias, removing quotes preserved by Docker env files."""
for name in names:
value = os.environ.pop(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
Comment on lines +20 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: _pop_first_env strips exactly one matched outer quote pair

start.py strips a single pair of matching outer quotes so Docker env-file quoting (e.g. KEY='value') no longer corrupts the credential or the /v1 URL suffix check. It only removes one layer; a doubly-quoted value like ''value'' would retain inner quotes, and an intentionally empty quoted value ("") collapses to empty and falls through to the next alias. Real credentials are unlikely to legitimately start and end with the same quote char, so the practical risk is low.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
if value:
return value
return ""
Expand Down
15 changes: 14 additions & 1 deletion docs/adr/0103-semantic-document-evidence-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ recognizable header/separator/data shape and otherwise preserves plain text.
5. Keep the frontend's raw-source fallback aligned with the persisted unit
labels. Persisted row units render as accessible tables; unresolved
structure remains visibly unresolved and actionable.
6. Apply the same narrow Markdown-table renderer to persisted image OCR.
VISION output may use multiple `TEXT` lines so row boundaries survive; its
caption names only visible entities, relationships, layout, and document
purpose rather than offering a generic one-sentence description. The
client allows 600 seconds for deep orchestrator work; a 180-second local
cutoff already terminated a valid live response before delivery.
7. Serialize replacement per source post and reject a same-image retry when
its content hash matches non-empty persisted OCR but the retry returns no
OCR. Provider completion is transport evidence, not permission to erase a
stronger prior observation.

## Rejected alternatives

Expand All @@ -54,6 +64,8 @@ recognizable header/separator/data shape and otherwise preserves plain text.
markup or image base64.
- The database keeps the existing normalized unit tables; this decision adds
no denormalized JSON field or new service.
- A weaker same-image VISION retry fails before replacement, leaving the
prior committed evidence available for a later orchestrator retry.
- Markdown dialects outside the narrow recognized shape remain plain text and
are reported as a future parser extension rather than guessed.

Expand All @@ -62,4 +74,5 @@ recognizable header/separator/data shape and otherwise preserves plain text.
The baseline's synthetic tests cover numeric superscript footnotes, marker
footnotes, nested `ol`/`ul`/`oi` order and depth, HTML/OOXML rows, Markdown
rows, React table rendering, and unresolved indentation. Full CI remains the
release gate.
release gate. A persistence regression test proves that an empty same-hash
VISION retry cannot delete previously observed OCR.
19 changes: 19 additions & 0 deletions frontend/src/PostBody.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export default meta;

type Story = StoryObj<typeof meta>;

const TINY_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";

export const MarkdownTableEvidence: Story = {
args: {
body: "| Workstream | State |\n| --- | --- |\n| Alpha | Ready |",
Expand Down Expand Up @@ -44,6 +47,22 @@ export const MarkdownTableFallback: Story = {
},
};

export const ImageOcrTableEvidence: Story = {
args: {
body: `<img src="data:image/png;base64,${TINY_PNG}" />`,
imageContent: [
{
unit_index: 0,
mime_type: "image/png",
status_code: "completed",
extracted_text: "| Workstream | State |\n| --- | --- |\n| Alpha | Ready |",
caption: "A synthetic workstream status table.",
tags: ["table"],
},
],
},
};

export const NumericFootnote: Story = {
args: {
body: "<p>Evidence remains attached to the source.</p><p><sup>1</sup> Source note.</p>",
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/PostBody.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,43 @@ describe("PostBody", () => {
expect(screen.getByText("Next action.")).toBeInTheDocument();
});

it("renders table-shaped image OCR as accessible evidence", () => {
render(
<PostBody
body={'<img src="data:image/png;base64,QQ==" />'}
imageContent={[
{
unit_index: 0,
mime_type: "image/png",
status_code: "completed",
extracted_text: "| Project | Status |\n| --- | --- |\n| Alpha | Ready |",
caption: "A synthetic project status table.",
tags: ["table"],
regions: [
{
region_index: 0,
x_ratio: 0,
y_ratio: 0,
width_ratio: 1,
height_ratio: 1,
status_code: "completed",
extracted_text: "| Owner | Action |\n| --- | --- |\n| Team A | Review |",
caption: "The table region assigns an action to a team.",
tags: ["assignment"],
},
],
},
]}
/>,
);

expect(screen.getAllByRole("table")).toHaveLength(2);
expect(screen.getByRole("columnheader", { name: "Project" })).toBeInTheDocument();
expect(screen.getByText("Ready")).toBeInTheDocument();
expect(screen.getByText("The table region assigns an action to a team.")).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Owner" })).toBeInTheDocument();
});

it("keeps a persisted Markdown table row searchable", () => {
render(
<PostBody
Expand Down
50 changes: 14 additions & 36 deletions frontend/src/PostBody.tsx
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -8,40 +8,6 @@ import { t } from "./i18n";
import type { PostContentUnit, PostImageContent } from "./api";
import type { ReactNode } from "react";

function parsePipeDelimitedTable(text: string): string[][] | null {
const rows = text
.split(/\r?\n/)
.map((row) => {
const cells = row.split("|").map((cell) => cell.trim());
if (cells[0] === "") cells.shift();
if (cells[cells.length - 1] === "") cells.pop();
return cells;
})
.filter((row) => !row.every((cell) => /^:?-{3,}:?$/.test(cell)))
.filter((row) => row.length > 1 && row.some(Boolean));
if (rows.length < 2 || rows.some((row) => row.length !== rows[0].length)) return null;
if (rows[0].length < 2) return null;
return rows;
}

function renderImageText(text: string) {
const rows = parsePipeDelimitedTable(text);
if (!rows) return <p>{text}</p>;
return (
<table className="post-body-table post-image-text-table">
<tbody>
{rows.map((row, rowIndex) => (
<tr key={`post-image-text-row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`post-image-text-cell-${rowIndex}-${cellIndex}`}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
);
}

const SAFE_EMBEDDED_IMAGE_SOURCE =
/^data:image\/(?:png|jpe?g|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon);base64,[A-Za-z0-9+/]+={0,2}$/i;

Expand All @@ -68,7 +34,7 @@ function renderImageEvidence(
{imageContent?.extracted_text ? (
<details className="post-image-text">
<summary>{t("Text detected in image")}</summary>
{renderImageText(imageContent.extracted_text)}
{renderExtractedText(imageContent.extracted_text)}
</details>
) : null}
{imageContent?.regions?.length ? (
Expand All @@ -77,7 +43,14 @@ function renderImageEvidence(
<ol>
{imageContent.regions.map((region) => (
<li key={region.region_index}>
<span>{region.caption || region.extracted_text || t("Unknown")}</span>
{region.caption ? <p>{region.caption}</p> : null}
{region.extracted_text ? (
<div className="post-image-region-text">
{renderExtractedText(region.extracted_text)}
</div>
) : region.caption ? null : (
t("Unknown")
)}
{region.tags.length ? (
<small>
{t("Image tags")}: {region.tags.join(", ")}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -294,6 +267,11 @@ function renderMarkdownBlocks(blocks: MarkdownBodyBlock[]): ReactNode[] {
});
}

function renderExtractedText(text: string): ReactNode {
const markdownBlocks = splitMarkdownTableBody(text);
return markdownBlocks ? renderMarkdownBlocks(markdownBlocks) : <p>{text}</p>;
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated

export function PostBody({
body,
imageContent = [],
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/postBodyDisplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ describe("splitPostBody", () => {
]);
});

it("unescapes pipe characters inside Markdown cells without accepting a short delimiter", () => {
expect(
splitMarkdownTableBody(
"| Project | Notes |\n| :--- | ---: |\n| Alpha | Ready \\| review |",
),
).toEqual([{ kind: "table", rows: [["Project", "Notes"], ["Alpha", "Ready | review"]] }]);
expect(splitMarkdownTableBody("| Project | Status |\n| -- | -- |\n| Alpha | Ready |")).toBeNull();
});

it("leaves a plain-text post unchanged so existing popups keep their wording", () => {
expect(splitPostBody("The full body text.")).toEqual([
{ kind: "text", text: "The full body text." },
Expand Down
30 changes: 17 additions & 13 deletions lineageweave/image_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ class ImageDescription:
Attributes:
extracted_text: OCR result -- every piece of legible text found in
the image, empty string if none.
caption: one-sentence description of what the image shows.
caption: factual description of the visible entities, relationships,
and layout that make the image useful as semantic evidence.
tags: short tags for the main objects/subjects, for independent
keyword search separate from the free-text caption.
"""
Expand Down Expand Up @@ -177,15 +178,18 @@ def describe(self, image_bytes: bytes, mime_type: str) -> ImageDescription: # p


_RESPONSE_FORMAT = (
"Examine this image. Reply with EXACTLY three lines, no extra commentary:\n"
"Examine this image. Reply with exactly the three labeled sections below and no "
"extra commentary. TEXT may span multiple lines; CAPTION and TAGS stay on their "
"labeled lines.\n"
"TEXT: <all legible text in the image, verbatim, or NONE if there is none. "
"If the image contains a table, preserve its row/column structure: one row "
"per line, with ' | ' between that row's cell values, in reading order -- "
"never flatten a table into an unstructured word list.>\n"
"CAPTION: <2-4 concise, evidence-grounded sentences describing the visible layout, "
"objects, relationships, directions, measurements, and labels; do not guess "
"anything that is not visible>\n"
"TAGS: <comma-separated short tags for the main objects/subjects>"
"If the image contains a table, preserve its row/column structure as a Markdown "
"pipe table: one row per line, with a separator row immediately after the visible "
"header. Never flatten a table into an unstructured word list or invent a header "
"that is not visible.>\n"
"CAPTION: <a concise factual paragraph naming visible named entities, their visible "
"relationships, the spatial or document layout, and the table/chart purpose when "
"present. Omit anything the pixels do not support.>\n"
"TAGS: <comma-separated short tags for visible named entities, objects, and document types>"
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
)
_REGION_RESPONSE_FORMAT = (
"Find distinct meaningful visual regions in this image for separate OCR and description. "
Expand Down Expand Up @@ -254,10 +258,10 @@ def _parse_description(content: str) -> ImageDescription:
multiline_field = "TEXT" if label == "TEXT" else None
continue

if re.match(r"^\s*[*_`>#\-\s]*[A-Za-z][A-Za-z0-9 _-]*\s*:", line):
multiline_field = None
continue
if multiline_field == "TEXT" and line.strip():
# A colon is common inside OCR (for example ``Date: 2026-08-21``).
# Only the known response labels above end the TEXT section;

@devin-ai-integration devin-ai-integration Bot Aug 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Removing generic-label termination widens what TEXT absorbs

The removed block in _parse_description (image_content.py on the old side) previously ended the multi-line TEXT section whenever any Word:-shaped line appeared. Now only the three known labels TEXT/CAPTION/TAGS terminate TEXT. This correctly preserves OCR lines with colons (the new test at test_image_content.py), and existing trailing-commentary tests still pass because multiline_field is reset to None once CAPTION/TAGS are seen. The behavioral edge: if a provider emits an unknown labeled line (e.g. OBSERVATION: ...) in the middle of the response before CAPTION while TEXT is still the active multiline field, that line is now absorbed into extracted_text rather than dropped. This is consistent with the ADR intent of treating TEXT as verbatim OCR, so not flagged as a bug.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

# treating every colon as a provider label loses real image text.
fields["TEXT"].append(_strip_outer_markdown_emphasis(line))

if not fields["TEXT"] and not fields["CAPTION"]:
Expand Down Expand Up @@ -289,7 +293,7 @@ def __init__(
api_key: str,
model: str | None = None,
*,
timeout: float = 180.0,
timeout: float = 600.0,
allow_insecure_http: bool = False,
) -> None:
parsed = urlparse(base_url)
Expand Down
37 changes: 36 additions & 1 deletion lineageweave/post_content_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,24 @@ async def persist_post_content(

Provider calls happen before the short database transaction. A failed or
unavailable embedding call writes no vector row; it never writes a zero or
guessed vector. The raw body remains in ``source_post`` for future retry.
guessed vector. A same-image retry cannot replace non-empty persisted OCR
with an empty result. The raw body remains in ``source_post`` for future
retry.
"""
normalized = normalized_result or normalize_post_body(body, vision_client)
chunks = chunk_by_source_body(body)
image_results = {result.chunk_index: result for result in normalized.image_results}
formatting = {hint.chunk_index: hint.style for hint in normalized.formatting_hints}
image_ocr_by_sha256: dict[str, bool] = {}
for chunk in chunks:
if chunk.unit_type != "image" or chunk.image_data is None:
continue
result = image_results.get(chunk.index)
description = result.description if result else None
content_sha256 = hashlib.sha256(chunk.image_data).hexdigest()
image_ocr_by_sha256[content_sha256] = image_ocr_by_sha256.get(
content_sha256, False
) or bool(description and description.extracted_text.strip())
Comment on lines +99 to +108

@devin-ai-integration devin-ai-integration Bot Aug 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: OCR-preservation guard protects whole-image OCR only, not region OCR

The guard computes image_ocr_by_sha256 from description.extracted_text and compares against post_content_image.extracted_text (post_content_persistence.py and 246-263). Region-level OCR (post_content_image_region.extracted_text) is not covered: a same-image retry that returns the whole-image OCR but loses all region OCR will still delete and re-insert regions with empty text without triggering the guard. This matches the ADR's whole-image scope but is a narrower protection than the general phrase "erase previously observed OCR" might imply.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +99 to +108

@devin-ai-integration devin-ai-integration Bot Aug 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: OCR-preservation guard keying and OR-aggregation verified correct

The guard builds image_ocr_by_sha256 from chunk_by_source_body image chunks keyed by content hash, OR-combining whether the new run produced non-empty OCR (post_content_persistence.py), then refuses replacement only when a persisted non-empty-OCR row shares a hash whose new value is False (:226-233). I confirmed image_results keying matches the existing prepared loop (chunk_index == chunk.index), that a genuinely different image (different hash) is not blocked, that first-time processing has no prior rows and proceeds, and that a total VISION failure (description None -> False) correctly refuses to erase prior OCR. Behavior matches the stated intent.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


prepared: list[tuple[Chunk, str, str | None]] = []
for chunk in chunks:
Expand Down Expand Up @@ -226,6 +238,29 @@ async def persist_post_content(
)

async with conn.transaction():
if image_ocr_by_sha256:
await conn.fetchval(
"select post_id from source_post where post_id = $1 for update",
post_id,
)
Comment on lines +245 to +249

@devin-ai-integration devin-ai-integration Bot Aug 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: OCR guard only takes the source_post FOR UPDATE lock when the post contains images

The select ... for update serialization lock in post_content_persistence.py is only acquired when image_ocr_by_sha256 is non-empty, i.e. only for posts that contain at least one embedded image. Image-less posts still perform the delete+reinsert of post_content_unit with no row lock, so concurrent persists of the same text-only post are not serialized. This matches pre-PR behavior (there was no lock before), so it is not a regression, but the ADR's claim to 'serialize replacement per source post' is only partially realized (image posts only).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

previous_images = await conn.fetch(
"""
select image.content_sha256, image.extracted_text
from post_content_unit unit
join post_content_image image using (post_content_unit_id)
where unit.post_id = $1
and nullif(btrim(image.extracted_text), '') is not null
""",
post_id,
)
if any(
row["content_sha256"] in image_ocr_by_sha256
and not image_ocr_by_sha256[row["content_sha256"]]
for row in previous_images
):
raise RuntimeError(
"refusing to replace non-empty image OCR with an empty retry result"
)
Comment on lines +245 to +267

@devin-ai-integration devin-ai-integration Bot Aug 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: OCR guard adds a lock + fetch on every image-bearing persist

The guard at post_content_persistence.py runs SELECT ... FOR UPDATE on source_post plus a fetch of prior image OCR for every persist of an image-containing post, not only for retries. This is the correct mechanism to serialize same-post replacement and protect stronger prior observations, and the overhead is small. Note the guard calls conn.fetch, which the older mock connections in tests/test_post_content_persistence.py and tests/test_post_content_worker.py do not implement — those tests only use text bodies, so image_ocr_by_sha256 stays empty and the block is skipped, avoiding an AttributeError. Any future test that persists an image body through those mocks would need a fetch stub.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +245 to +267

@devin-ai-integration devin-ai-integration Bot Aug 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Normalize-only backfill silently skips image posts with existing OCR

The preservation guard raises when a same-hash image previously had OCR but the current run yields none. Under --normalize-only, NullImageContentClient always yields empty OCR, so any post with persisted OCR is skipped and its text/DOM units are never refreshed.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +260 to +267

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: OCR preservation can permanently block reprocessing of an image that legitimately lost its text

The new guard in post_content_persistence.py refuses to persist when a previously-stored image (matched by content_sha256) had non-empty OCR but the current run produced empty OCR for that same image hash. This is the intended conservative behavior per ADR 0103. The follow-up implication worth noting: if an image genuinely has no extractable text now (or the provider consistently returns empty OCR for it), every retry/backfill of that post will raise ImageOcrPreservationError and skip the post forever — meaning caption/tag/structure/embedding updates for the whole post can never be re-persisted while that one image's prior OCR remains. The backfill loop (backfill_post_content.py) counts these as skipped_posts and continues, so a batch is not aborted, but an operator cannot force a legitimate re-process without manually clearing the prior OCR. Design decision, flagged for awareness.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

await conn.execute("delete from post_content_unit where post_id = $1", post_id)
unit_ids: dict[int, str] = {}
for chunk, unit_text, style in prepared:
Expand Down
10 changes: 10 additions & 0 deletions scripts/backfill_post_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ def _parser() -> argparse.ArgumentParser:
return parser


async def _ensure_open_connection(
conn: asyncpg.Connection, target_dsn: str
) -> asyncpg.Connection:
"""Reconnect after a database restart without repeating VISION work."""
if not conn.is_closed():
return conn
return await asyncpg.connect(target_dsn)


async def backfill_post_content(
target_dsn: str,
raw_post_ids: list[str] | None,
Expand Down Expand Up @@ -217,6 +226,7 @@ async def backfill_post_content(
if described_images == 0 and not normalized.text.strip():
result["skipped_posts"] += 1
continue
conn = await _ensure_open_connection(conn, target_dsn)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines 229 to +232

@devin-ai-integration devin-ai-integration Bot Aug 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Reconnect guard relies on asyncpg is_closed, which only reflects locally-detected closure

_ensure_open_connection in backfill_post_content.py reconnects only when conn.is_closed() is true. It is invoked after the long normalize_post_body (VISION) call and before persist_post_content, which is the right place to recover from a DB restart during expensive VISION work. However, conn.fetchrow(...) at line 204 runs before the reconnect check, so a connection dropped before that fetch would still raise rather than recover; and is_closed() only reflects closure asyncpg has already observed. Behavior is a best-effort improvement, not a full reconnect-with-retry.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

await persist_post_content(

@devin-ai-integration devin-ai-integration Bot Aug 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Backfill reconnect only covers the VISION gap, not a drop during persist's embedding work

_ensure_open_connection in backfill_post_content.py is invoked at line 219, after the long normalize_post_body VISION call and before persist_post_content. This addresses a connection dropped during VISION (now allowed up to 600s). However, persist_post_content itself performs embedding calls via asyncio.to_thread (lineageweave/post_content_persistence.py:194-238) before opening its transaction; if the idle connection is dropped during that embedding work, the subsequent conn.transaction() fails and the reconnect helper does not cover it. The worker path (backend/app/post_content_worker.py:235) avoids this by acquiring a fresh pooled connection, but the backfill script keeps one long-lived connection. This is an incremental improvement rather than a complete fix.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

conn,
str(row["post_id"]),
Expand Down
Loading
Loading