Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
67 changes: 57 additions & 10 deletions pkg-py/src/commons/_citations.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,27 @@

import html
import re
from collections.abc import Sequence
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, get_args
from typing import TYPE_CHECKING, Any, Literal, get_args

from chatlas import ContentToolResult, Turn
from chatlas.types import ContentText

from ._measures import Measure, measure_schema_text
from ._prompt import read_prompt
from ._provenance import Tag
from ._provenance import TAG_EXTRA_KEY, Tag, escape_attr

if TYPE_CHECKING:
from ._context_layer import ContextLayer
from ._data_source import DataSource

__all__ = [
"CitationDecision",
"CitationRequest",
"CorpusEntry",
"ParsedCitation",
"build_citation_corpus",
"citation_aside_html",
"citation_reminder_text",
"match_citation",
Expand Down Expand Up @@ -187,6 +193,52 @@ def match_citation(quote: str, corpus: Sequence[CorpusEntry]) -> CorpusEntry | N
)


def build_citation_corpus(
context_layer: ContextLayer | None,
measures: Iterable[Measure],
sources: Mapping[str, DataSource],
) -> list[CorpusEntry]:
"""Collect the trusted text an answer's citations are verified against.

Which text is citable and under which label is a cross-language contract
pinned by ``tests/shared/citation-corpus.json``. Matching returns the
first entry containing the quote, so the specific sources are added ahead
of the general documentation corpus.
"""
entries: list[CorpusEntry] = []

def add(label: str, kind: CitationKind, texts: Iterable[str | None]) -> None:
entries.extend(
CorpusEntry(label=label, kind=kind, text=text) for text in texts if text
)

# A measure block names its sources only for an agent that has several, so
# the corpus holds the block search_pool actually presented.
source_names = tuple(sources) if len(sources) > 1 else ()
for record in measures:
add(
f"{record.name} definition",
"definition",
[measure_schema_text(record, source_names=source_names)],
)

for name, source in sources.items():
dictionary = source.dictionary
if dictionary is None:
continue
add(
f"{name} dictionary",
"schema",
[dictionary.description, dictionary.details],
)
for table in dictionary.tables:
add(f"{table} table", "schema", [dictionary.entry_text(table)])

docs = context_layer.docs if context_layer is not None else ()
add("documentation", "prose", docs)
return entries


def citation_aside_html(quote: str, explanation: str, label: str, kind: str) -> str:
"""Render a verified citation as the aside shinychat displays.

Expand All @@ -204,23 +256,18 @@ def citation_aside_html(quote: str, explanation: str, label: str, kind: str) ->
f"{html.escape(label, quote=False)}</span></span>\n\n"
)
return (
f'<shiny-aside label="{_escape_attr(label)}">'
f'<shiny-aside label="{escape_attr(label)}">'
f"{title}{reason}{blockquote}</shiny-aside>"
)


# Ampersands first, so the entities this generates are not escaped again.
def _escape_attr(text: str) -> str:
return text.replace("&", "&amp;").replace('"', "&quot;")


def tool_result(value: Any, tag: Tag | None = None) -> ContentToolResult:
"""A tool result carrying the provenance tag of the output it holds.

The tag is read back off ``extra`` when the turn is classified, so it is
set here rather than at the point a result is added to the conversation.
"""
return ContentToolResult(value=value, extra={"commons_tag": tag})
return ContentToolResult(value=value, extra={TAG_EXTRA_KEY: tag})


def citation_reminder_text() -> str:
Expand Down
84 changes: 80 additions & 4 deletions pkg-py/src/commons/_provenance.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
"""A/B/C provenance: how much an answer can be trusted.

The truth table and the display copy are a cross-language contract pinned by
``tests/shared/provenance.json``; change that fixture, not just this file.
``pkg-r/R/provenance.R`` implements the same contract for R.
The truth table, the display copy, and which outcomes render a marker are a
cross-language contract pinned by ``tests/shared/provenance.json``; change
that fixture, not just this file. ``pkg-r/R/provenance.R`` implements the same
contract for R.
"""

from __future__ import annotations

import enum
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Final

__all__ = ["PROVENANCE_DISPLAY", "ProvenanceDisplay", "Tag", "derive_provenance_tag"]
from chatlas import ContentToolResult, Turn

__all__ = [
"PROVENANCE_DISPLAY",
"TAG_EXTRA_KEY",
"ProvenanceDisplay",
"Tag",
"collect_appended_tags",
"derive_provenance_tag",
"escape_attr",
"provenance_aside",
]

# Where a tool records how much its result can be trusted. R writes the same
# key, so a trajectory written by either package classifies in both.
TAG_EXTRA_KEY: Final = "commons_tag"


class Tag(enum.StrEnum):
Expand Down Expand Up @@ -80,3 +97,62 @@ def derive_provenance_tag(tags: Sequence[Tag], verified: bool) -> Tag | None:
if Tag.A in tags:
return Tag.A
return None


def collect_appended_tags(turns: Sequence[Turn], from_index: int) -> list[Tag]:
"""Gather the tags the tools of one exchange set on their results.

``from_index`` is the turn count read before the exchange started, so a
tag an earlier answer earned cannot classify this one.
"""
tags: list[Tag] = []
for turn in turns[from_index:]:
for content in turn.contents:
if not isinstance(content, ContentToolResult):
continue
value = (content.extra or {}).get(TAG_EXTRA_KEY)
try:
tags.append(Tag(value))
# Nothing validates `extra`, and a restored conversation arrives
# as JSON: an unreadable tag must not cost the exchange the tags
# that are readable.
except ValueError:
continue
return tags


def escape_attr(text: str) -> str:
"""Escape ``&`` and ``"`` for a double-quoted HTML attribute value.

Nothing else is escaped, so the result belongs in a double-quoted
attribute only: never a text node, never a single-quoted attribute.
"""
# Ampersands first, so the entities this generates are not escaped again.
return text.replace("&", "&amp;").replace('"', "&quot;")


# Upgrades when the UI mounts the aside, and renders as nothing until then.
_INFO_CONTROL: Final = (
'<commons-provenance-info class="commons-provenance-info">'
"</commons-provenance-info>"
)


def provenance_aside(tag: Tag | None, *, include_cited: bool = False) -> str:
"""Render the marker that follows a classified answer.

Which outcomes render is a cross-language contract pinned by
``tests/shared/provenance.json``. A live answer omits the "Cited" marker,
because the verified citation's own aside already says as much; a review
context passes ``include_cited`` to see every outcome.

No icon: its URL comes from the served asset bundle, which arrives with
the Python UI (see ``citation_aside_html``).
"""
if tag is None or (tag is Tag.B and not include_cited):
return ""
display = PROVENANCE_DISPLAY[tag]
return (
f'<shiny-aside label="{escape_attr(display.label)}">'
f"{display.body} {_INFO_CONTROL}</shiny-aside>"
)
53 changes: 53 additions & 0 deletions pkg-py/src/commons/_reminders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Reminders appended to a user's turn.

The wording and which models earn the concise reminder are a cross-language
contract pinned by ``tests/shared/turn-reminders.json``; change that fixture,
not just this file. ``pkg-r/R/turn-reminder.R`` holds the same contract for R.
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Any, Final

from chatlas.types import ContentText

from ._prompt import is_claude_5_model

__all__ = [
"CLAUDE_5_TURN_REMINDER",
"RESTORED_CONVERSATION_REMINDER",
"ContentTurnReminder",
"append_restored_conversation_reminder",
"append_turn_reminder",
]


class ContentTurnReminder(ContentText):
"""Text the model reads and the UI leaves out.

A provider only accepts content it knows, so a reminder travels as text;
its own type is what lets a UI recognize and skip it.
"""


CLAUDE_5_TURN_REMINDER: Final = "<reminder>Be concise as a default.</reminder>"

RESTORED_CONVERSATION_REMINDER: Final = (
"<reminder>The Python state associated with this restored conversation is "
"unavailable. Do not assume that objects, loaded packages, or result "
"handles from earlier run_python calls still exist. Re-run needed tools, "
"recreate objects, and reload packages before continuing.</reminder>"
)


def append_turn_reminder(inputs: Sequence[Any], model: str | None) -> list[Any]:
"""Add the concise reminder for the models that need it."""
if not is_claude_5_model(model):
return list(inputs)
return [*inputs, ContentTurnReminder(text=CLAUDE_5_TURN_REMINDER)]


def append_restored_conversation_reminder(inputs: Sequence[Any]) -> list[Any]:
"""Tell the model that the session behind its earlier results is gone."""
return [*inputs, ContentTurnReminder(text=RESTORED_CONVERSATION_REMINDER)]
79 changes: 78 additions & 1 deletion pkg-py/tests/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@

import json
from pathlib import Path
from typing import Any
from typing import Annotated, Any, Literal

from pydantic import Field

from commons._measures import Injected, Measure, as_measure, measure

SHARED_DIR = Path(__file__).resolve().parents[2] / "tests" / "shared"

Expand All @@ -21,3 +25,76 @@ def load_shared_fixture(name: str) -> Any:
f"installed wheel."
)
return json.loads(path.read_text(encoding="utf-8"))


_SCALARS: dict[str, Any] = {
"string": str,
"integer": int,
"number": float,
"boolean": bool,
}


def _fixture_annotation(spec: dict[str, Any]) -> Any:
kind = spec["type"]
if kind == "enum":
return Literal[tuple(spec["values"])] # type: ignore[misc]
if kind == "array":
return list[_fixture_annotation(spec["items"])]
return _SCALARS[kind]


def fixture_measure(spec: dict[str, Any]) -> Measure:
"""Build a measure from a fixture spec through the production ``@measure``.

A real function is generated because ``@measure`` inspects a signature,
not a spec; each described argument keeps its position, required arguments
and injected arguments come first (Python requires that), and defaulted
arguments follow. A case declaring a required argument after an optional
one cannot be built without reordering, which would render a different
argument order than the R runner, so it is rejected rather than built.
"""
arguments = spec.get("arguments") or []
injected = spec.get("injected") or []

optional_seen = False
for argument in arguments:
if argument["required"] and optional_seen:
raise ValueError(
f"Fixture case {spec['name']!r} declares required argument "
f"{argument['name']!r} after an optional one; Python requires "
"defaulted parameters last, so this runner cannot preserve "
"declaration order for that case."
)
optional_seen = optional_seen or not argument["required"]

namespace: dict[str, Any] = {}
required_params: list[str] = []
optional_params: list[str] = []

for argument in arguments:
type_name = f"_{argument['name']}_type"
namespace[type_name] = Annotated[
_fixture_annotation(argument), Field(description=argument["description"])
]
if argument["required"]:
required_params.append(f"{argument['name']}: {type_name}")
else:
default_name = f"_{argument['name']}_default"
namespace[default_name] = argument["default"]
optional_params.append(f"{argument['name']}: {type_name} = {default_name}")

injected_params: list[str] = []
for injected_name in injected:
type_name = f"_{injected_name}_type"
namespace[type_name] = Injected[Any]
injected_params.append(f"{injected_name}: {type_name}")

params = ", ".join(required_params + injected_params + optional_params)
exec(f"def {spec['name']}({params}) -> None: ...", namespace) # noqa: S102
func = namespace[spec["name"]]

decorated = measure(description=spec["description"], name=spec["name"])(func)
record = as_measure(decorated)
assert record is not None
return record
Loading
Loading