@@ -3,6 +3,28 @@ import { extractCitationIds } from "./citations.js";
33
44const SENTENCE_SPLIT = / (?< = [ . ! ? ] ) \s + (? = [ A - Z ( " [ ] ) | (?: \n + ) / ;
55
6+ const ABSOLUTE_WORDS =
7+ / \b ( a l w a y s | n e v e r | c o m p l e t e l y | e n t i r e l y | d r a m a t i c a l l y | e v e r y o n e | n o o n e | a l l r e g i o n s | e v e r y s q u a d ) \b / gi;
8+
9+ function softenText ( excerpt : string ) : string {
10+ return excerpt
11+ . replace ( / \b a l w a y s \b / gi, "often" )
12+ . replace ( / \b n e v e r \b / gi, "rarely" )
13+ . replace ( / \b c o m p l e t e l y \b / gi, "substantially" )
14+ . replace ( / \b e n t i r e l y \b / gi, "largely" )
15+ . replace ( / \b d r a m a t i c a l l y \b / gi, "notably" )
16+ . replace ( / \b e v e r y o n e \b / gi, "many teammates" )
17+ . replace ( / \b n o o n e \b / gi, "few people" )
18+ . replace ( / \b a l l r e g i o n s \b / gi, "several regions" )
19+ . replace ( / \b e v e r y s q u a d \b / gi, "multiple squads" )
20+ . replace ( / ( \d + % ) / g, "about $1 (verify)" )
21+ . replace ( / ( \$ \d [ \d , ] * (?: \. \d + ) ? ) / g, "about $1 (verify)" ) ;
22+ }
23+
24+ function pickCiteId ( allowlist : string [ ] ) : string | undefined {
25+ return allowlist . find ( ( id ) => / ^ ( a c h | f b | g o a l | d o c | r e x p ) _ / . test ( id ) ) ;
26+ }
27+
628/** Claims that look substantive but lack any [id] citation nearby. */
729export function runFaithfulnessLint ( text : string , allowlist : string [ ] = [ ] ) : BiasFinding [ ] {
830 const findings : BiasFinding [ ] = [ ] ;
@@ -19,6 +41,11 @@ export function runFaithfulnessLint(text: string, allowlist: string[] = []): Bia
1941 category : "invention" ,
2042 message : `Citation [${ id } ] is not in the evidence allowlist — remove or replace with a real evidence id.` ,
2143 excerpt : `[${ id } ]` ,
44+ suggestedFix : {
45+ action : "strip_cite" ,
46+ label : "Remove unknown cite" ,
47+ newText : "" ,
48+ } ,
2249 } ) ;
2350 }
2451 }
@@ -33,9 +60,7 @@ export function runFaithfulnessLint(text: string, allowlist: string[] = []): Bia
3360 for ( const sentence of sentences ) {
3461 if ( / ^ ( s u m m a r y | s t r e n g t h s | g r o w t h | o v e r a l l | n o t e | p r i v a t e ) \b / i. test ( sentence ) ) continue ;
3562 if ( ! / \[ [ a - z ] + _ [ A - Z a - z 0 - 9 _ - ] + \] / . test ( sentence ) ) {
36- // Skip questions / instructions
3763 if ( / \? $ / . test ( sentence ) || / ^ ( p l e a s e | c o n s i d e r | r e m e m b e r ) \b / i. test ( sentence ) ) continue ;
38- // Skip name-ish lists (mostly Title Case tokens, almost no verbs/stopwords)
3964 const words = sentence . split ( / \s + / ) . filter ( Boolean ) ;
4065 const titleish = words . filter ( ( w ) => / ^ [ A - Z ] [ a - z ] + / . test ( w . replace ( / [ . , ] / g, "" ) ) ) . length ;
4166 if ( words . length >= 3 && titleish / words . length >= 0.7 ) continue ;
@@ -45,33 +70,83 @@ export function runFaithfulnessLint(text: string, allowlist: string[] = []): Bia
4570 }
4671
4772 if ( ungrounded >= 2 ) {
73+ const sample = samples [ 0 ] ;
74+ const citeId = pickCiteId ( allowlist ) ;
75+ const softened = sample ? softenText ( sample ) : undefined ;
4876 findings . push ( {
4977 id : "ungrounded_claims" ,
5078 severity : "warn" ,
5179 category : "invention" ,
5280 message : `${ ungrounded } long sentence(s) lack evidence citations. Prefer claims tied to [ach_…]/[fb_…]/[goal_…] ids.` ,
53- excerpt : samples [ 0 ] ,
81+ excerpt : sample ,
82+ suggestedFix : citeId
83+ ? {
84+ action : "cite" ,
85+ label : `Cite [${ citeId } ]` ,
86+ citeId,
87+ newText : sample ? `${ sample . replace ( / \. * $ / , "" ) } [${ citeId } ].` : undefined ,
88+ }
89+ : softened && sample
90+ ? {
91+ action : "soften" ,
92+ label : "Soften absolute language" ,
93+ newText : softened ,
94+ }
95+ : undefined ,
5496 } ) ;
5597 }
5698
57- // Numeric claims without citations are high invention risk (% is non-word — no trailing \b)
5899 const metricClaims = [
59100 ...body . matchAll ( / ( \d + % | \$ \d [ \d , ] * (?: \. \d + ) ? | \b \d { 2 , } \s * (?: u s e r s | P R s | t i c k e t s | c u s t o m e r s ) \b ) / gi) ,
60101 ] ;
61102 for ( const m of metricClaims . slice ( 0 , 5 ) ) {
62103 const idx = m . index ?? 0 ;
63104 const window = body . slice ( Math . max ( 0 , idx - 80 ) , idx + 80 ) ;
64105 if ( ! / \[ [ a - z ] + _ [ A - Z a - z 0 - 9 _ - ] + \] / . test ( window ) ) {
106+ const citeId = pickCiteId ( allowlist ) ;
107+ const excerpt = m [ 0 ] ;
65108 findings . push ( {
66109 id : `metric_${ idx } ` ,
67110 severity : "warn" ,
68111 category : "invention" ,
69- message : `Metric “${ m [ 0 ] } ” appears without a nearby evidence citation — verify or cite the source.` ,
70- excerpt : m [ 0 ] ,
112+ message : `Metric “${ excerpt } ” appears without a nearby evidence citation — verify or cite the source.` ,
113+ excerpt,
114+ suggestedFix : citeId
115+ ? {
116+ action : "cite" ,
117+ label : `Cite [${ citeId } ] near metric` ,
118+ citeId,
119+ newText : `${ excerpt } [${ citeId } ]` ,
120+ }
121+ : {
122+ action : "soften" ,
123+ label : "Soften / qualify metric" ,
124+ newText : softenText ( excerpt ) ,
125+ } ,
71126 } ) ;
72127 }
73128 }
74129
130+ // Absolute phrasing without cites
131+ for ( const m of body . matchAll ( ABSOLUTE_WORDS ) ) {
132+ const idx = m . index ?? 0 ;
133+ const window = body . slice ( Math . max ( 0 , idx - 40 ) , idx + ( m [ 0 ] ?. length ?? 0 ) + 40 ) ;
134+ if ( / \[ [ a - z ] + _ [ A - Z a - z 0 - 9 _ - ] + \] / . test ( window ) ) continue ;
135+ const word = m [ 0 ] ;
136+ findings . push ( {
137+ id : `absolute_${ idx } ` ,
138+ severity : "info" ,
139+ category : "invention" ,
140+ message : `Absolute wording “${ word } ” is easy to overclaim without evidence.` ,
141+ excerpt : word ,
142+ suggestedFix : {
143+ action : "soften" ,
144+ label : "Soften wording" ,
145+ newText : softenText ( word ) ,
146+ } ,
147+ } ) ;
148+ }
149+
75150 return findings ;
76151}
77152
@@ -88,3 +163,20 @@ export function mergeLintFindings(...groups: BiasFinding[][]): BiasFinding[] {
88163 }
89164 return out ;
90165}
166+
167+ /** Apply a suggested faithfulness fix to draft text. Returns null if excerpt not found. */
168+ export function applyFaithfulnessFix (
169+ text : string ,
170+ finding : Pick < BiasFinding , "excerpt" | "suggestedFix" > ,
171+ ) : string | null {
172+ const fix = finding . suggestedFix ;
173+ const excerpt = finding . excerpt ;
174+ if ( ! fix || ! excerpt ) return null ;
175+ if ( fix . action === "strip_cite" ) {
176+ if ( ! text . includes ( excerpt ) ) return null ;
177+ return text . replace ( excerpt , "" ) . replace ( / + / g, " " ) . replace ( / \. / g, "." ) ;
178+ }
179+ if ( ! fix . newText ) return null ;
180+ if ( ! text . includes ( excerpt ) ) return null ;
181+ return text . replace ( excerpt , fix . newText ) ;
182+ }
0 commit comments