meta_language/grammar/inference/advisor/
llm.rs1use super::{
2 AdviceSource, ConceptNamingAdvisor, INFERENCE_NAMING_CONCEPTS, MdlMergeAdvisor, MergeAdvisor,
3 MergeRequest, MergeScore, NameCandidate, NamingAdvisor, NamingRequest, validate_name_candidate,
4};
5
6pub trait LlmClient: Send + Sync {
8 fn complete(&self, prompt: &str) -> Result<String, LlmError>;
10}
11
12#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct LlmError {
15 message: String,
16}
17
18impl LlmError {
19 #[must_use]
21 pub fn new(message: impl Into<String>) -> Self {
22 Self {
23 message: message.into(),
24 }
25 }
26
27 #[must_use]
29 pub fn message(&self) -> &str {
30 &self.message
31 }
32}
33
34impl std::fmt::Display for LlmError {
35 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 formatter.write_str(&self.message)
37 }
38}
39
40impl std::error::Error for LlmError {}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct LlmNamingAdvisor<C> {
45 client: C,
46 fallback: ConceptNamingAdvisor,
47}
48
49impl<C> LlmNamingAdvisor<C> {
50 #[must_use]
52 pub const fn new(client: C) -> Self {
53 Self {
54 client,
55 fallback: ConceptNamingAdvisor,
56 }
57 }
58
59 #[must_use]
61 pub const fn with_fallback(client: C, fallback: ConceptNamingAdvisor) -> Self {
62 Self { client, fallback }
63 }
64}
65
66impl<C> NamingAdvisor for LlmNamingAdvisor<C>
67where
68 C: LlmClient,
69{
70 fn propose_names(&self, request: &NamingRequest<'_>) -> Vec<NameCandidate> {
71 let deterministic = self.fallback.propose_names(request);
72 let Ok(response) = self.client.complete(&naming_prompt(request)) else {
73 return deterministic;
74 };
75
76 let candidates = parse_name_candidates(&response)
77 .into_iter()
78 .filter(|candidate| validate_name_candidate(request, candidate))
79 .collect::<Vec<_>>();
80
81 if candidates.is_empty() {
82 deterministic
83 } else {
84 candidates
85 }
86 }
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct LlmMergeAdvisor<C> {
92 client: C,
93 fallback: MdlMergeAdvisor,
94}
95
96impl<C> LlmMergeAdvisor<C> {
97 #[must_use]
99 pub const fn new(client: C) -> Self {
100 Self {
101 client,
102 fallback: MdlMergeAdvisor,
103 }
104 }
105
106 #[must_use]
108 pub const fn with_fallback(client: C, fallback: MdlMergeAdvisor) -> Self {
109 Self { client, fallback }
110 }
111}
112
113impl<C> MergeAdvisor for LlmMergeAdvisor<C>
114where
115 C: LlmClient,
116{
117 fn rank_merges(&self, request: &MergeRequest<'_>) -> Vec<MergeScore> {
118 let deterministic = self.fallback.rank_merges(request);
119 let Ok(response) = self.client.complete(&merge_prompt(request)) else {
120 return deterministic;
121 };
122 let Some(parsed) = parse_merge_scores(&response, request.candidates.len()) else {
123 return deterministic;
124 };
125
126 parsed
127 .into_iter()
128 .zip(deterministic)
129 .map(|(score, deterministic)| {
130 let mut score = score.clamp(0.0, 1.0);
131 if deterministic.score < 0.5 {
132 score = score.min(deterministic.score);
133 }
134 MergeScore {
135 score,
136 source: AdviceSource::Llm,
137 }
138 })
139 .collect()
140 }
141}
142
143fn naming_prompt(request: &NamingRequest<'_>) -> String {
144 let concepts = INFERENCE_NAMING_CONCEPTS
145 .iter()
146 .map(|concept| format!("{}={}", concept.id, concept.name))
147 .collect::<Vec<_>>()
148 .join(", ");
149 format!(
150 "Suggest one grammar rule name as name|concept.\nExpression: {}\nSamples: {:?}\nConcepts: {concepts}",
151 request.rule_expr, request.sample_yields
152 )
153}
154
155fn merge_prompt(request: &MergeRequest<'_>) -> String {
156 let candidates = request
157 .candidates
158 .iter()
159 .map(|candidate| format!("{}<-{}", candidate.winner, candidate.loser))
160 .collect::<Vec<_>>()
161 .join(", ");
162 format!("Score merge candidates in order with numbers from 0 to 1: {candidates}")
163}
164
165fn parse_name_candidates(response: &str) -> Vec<NameCandidate> {
166 response.lines().filter_map(parse_name_candidate).collect()
167}
168
169fn parse_name_candidate(line: &str) -> Option<NameCandidate> {
170 let line = line.trim();
171 if line.is_empty() {
172 return None;
173 }
174
175 let (name, concept) = line
176 .split_once('|')
177 .map_or((line, None), |(name, concept)| {
178 let concept = concept.trim();
179 let concept = if concept.is_empty() || concept.eq_ignore_ascii_case("none") {
180 None
181 } else {
182 Some(concept.to_string())
183 };
184 (name, concept)
185 });
186 let name = name.trim().trim_matches(['"', '\'', '`']);
187 (!name.is_empty()).then(|| NameCandidate {
188 name: name.to_string(),
189 concept,
190 source: AdviceSource::Llm,
191 })
192}
193
194fn parse_merge_scores(response: &str, expected_len: usize) -> Option<Vec<f64>> {
195 let scores = response
196 .split(|character: char| character.is_ascii_whitespace() || matches!(character, ',' | ';'))
197 .filter_map(|token| token.trim().parse::<f64>().ok())
198 .collect::<Vec<_>>();
199
200 (scores.len() == expected_len && scores.iter().all(|score| score.is_finite())).then_some(scores)
201}