Coverage for src / quber / playground / answers / shapes.py: 100%

44 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-09-23 22:14 -0400

1"""The defined return values an answer can take. 

2 

3A question that names a figure should come back as that figure, not as a 

4sentence containing it. These models are the shapes an answer is allowed to 

5have; which shape a given question gets is decided in `routing`. 

6 

7Every shape carries the ids it was read from. Table cells arrive tagged 

8(`<td id="t0-15-1">$2.97</td>`), so a scalar can name the exact cell its 

9value came from, and the value it reports can be checked against the text 

10printed in that cell — a grounding test no prose answer admits. 

11""" 

12 

13from __future__ import annotations 

14 

15from decimal import Decimal 

16from typing import List, Literal, Optional, Union 

17 

18from pydantic import BaseModel, Field, computed_field 

19 

20from quber.playground.answers.figures import as_number 

21 

22 

23class Scalar(BaseModel): 

24 """One figure, as the source prints it.""" 

25 

26 kind: Literal["scalar"] = "scalar" 

27 value: str = Field( 

28 description=( 

29 "The figure exactly as printed in the source, including its currency " 

30 "sign, separators, and any trailing sign: '$14.47', '4.6x', '92.6%', " 

31 "'(0.02)'. No words, no sentence, no explanation." 

32 ) 

33 ) 

34 

35 unit: Optional[str] = Field( 

36 default=None, 

37 description="What the number counts: 'USD per share', 'USD thousands', 'percent', 'x'.", 

38 ) 

39 period: Optional[str] = Field( 

40 default=None, 

41 description="The period or as-of date the figure belongs to, as printed: 'March 31, 2026'.", 

42 ) 

43 label: Optional[str] = Field( 

44 default=None, 

45 description="The line item as printed in the source: 'Book Value Per Share of Common Stock'.", 

46 ) 

47 source_id: Optional[str] = Field( 

48 default=None, 

49 description=( 

50 "The id of the single cell or chunk this figure was read from. Must " 

51 "appear verbatim in the context." 

52 ), 

53 ) 

54 

55 @computed_field # type: ignore[prop-decorator] 

56 @property 

57 def number(self) -> Optional[Decimal]: 

58 """The figure as a plain number, sign applied and separators removed. 

59 

60 Derived from `value` rather than asked of the model. The conversion is 

61 deterministic, and a model asked for it alongside the figure returns the 

62 figure every time and the number only most of the time: one question put 

63 four times came back with the figure four times and the number three. 

64 A numeric column with unpredictable holes is worse than one computed in 

65 a single place and tested there. 

66 """ 

67 return as_number(self.value) 

68 

69 

70class Point(BaseModel): 

71 """One labelled figure inside a series.""" 

72 

73 label: str = Field(description="What this point is: a period, a segment, a category.") 

74 value: str = Field(description="The figure as printed.") 

75 source_id: Optional[str] = None 

76 

77 @computed_field # type: ignore[prop-decorator] 

78 @property 

79 def number(self) -> Optional[Decimal]: 

80 """The figure as a number, read from `value` on the same terms a scalar's is.""" 

81 return as_number(self.value) 

82 

83 

84class Series(BaseModel): 

85 """One measure across several labels — periods, segments, buckets.""" 

86 

87 kind: Literal["series"] = "series" 

88 measure: str = Field(description="What is being measured across the points.") 

89 unit: Optional[str] = None 

90 points: List[Point] = Field(min_length=1) 

91 

92 

93class Grid(BaseModel): 

94 """A rectangle of values, when the question asks for a whole table.""" 

95 

96 kind: Literal["grid"] = "grid" 

97 columns: List[str] 

98 rows: List[List[str]] = Field(description="Each row holds one string per column, as printed.") 

99 unit: Optional[str] = None 

100 

101 

102class Prose(BaseModel): 

103 """Free text, for questions that ask for explanation rather than a figure.""" 

104 

105 kind: Literal["prose"] = "prose" 

106 text: str 

107 

108 

109class Unanswerable(BaseModel): 

110 """The context does not contain the answer. 

111 

112 A distinct shape rather than an empty scalar: a caller in code must be able 

113 to tell 'not present' from 'zero' without reading English. 

114 """ 

115 

116 kind: Literal["unanswerable"] = "unanswerable" 

117 reason: str = Field(description="One sentence: what was missing from the context.") 

118 

119 

120Payload = Union[Scalar, Series, Grid, Prose, Unanswerable] 

121 

122 

123class Answer(BaseModel): 

124 """The single return value. Always this type; the payload varies.""" 

125 

126 payload: Payload = Field(discriminator="kind") 

127 cited_ids: List[str] = Field( 

128 default_factory=list, 

129 description="Chunk ids and/or table cell ids supporting the payload.", 

130 )