Files
AiRust_Backend/src/training/websearch.rs
T
frostyripper1 61cd0b889d MetaCommit
2026-05-19 13:37:12 +02:00

464 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// src/training/websearch.rs
#![allow(dead_code)]
use crate::data_ls::schema::EnglishSample;
use anyhow::{anyhow, Result};
use ollama_rs::generation::chat::request::ChatMessageRequest;
use ollama_rs::generation::chat::ChatMessage;
use ollama_rs::generation::completion::request::GenerationRequest;
use ollama_rs::generation::parameters::{FormatType, KeepAlive};
use ollama_rs::generation::embeddings::request::{EmbeddingsInput, GenerateEmbeddingsRequest};
use ollama_rs::models::ModelOptions;
use ollama_rs::Ollama;
use reqwest::{header, Client};
use scraper::{Html, Selector};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use urlencoding::encode;
fn extract_json_from_response(text: &str) -> Option<String> {
let trimmed = text.trim();
let without_fence = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```json "))
.or_else(|| trimmed.strip_prefix("```json\n"))
.or_else(|| trimmed.strip_prefix("```"))
.unwrap_or(trimmed)
.trim_start();
let without_trailing = without_fence
.strip_suffix("```")
.or_else(|| without_fence.strip_suffix("```\n"))
.or_else(|| without_fence.strip_suffix("``` "))
.unwrap_or(without_fence)
.trim();
let start = without_trailing.find('{')?;
let end = without_trailing.rfind('}')?;
if start >= end {
return None;
}
let json_str = &without_trailing[start..=end];
if json_str.starts_with('{') && json_str.ends_with('}') {
Some(json_str.to_string())
} else {
None
}
}
fn is_json_braces_balanced(text: &str) -> bool {
let mut balance = 0;
for c in text.chars() {
if c == '{' {
balance += 1;
} else if c == '}' {
balance -= 1;
if balance < 0 {
return false;
}
}
}
balance == 0
}
fn repair_json_braces(json: &str) -> String {
let mut repaired = json.trim_end().to_string();
let mut balance = 0;
for c in repaired.chars() {
if c == '{' {
balance += 1;
} else if c == '}' {
balance -= 1;
}
}
while balance > 0 {
repaired.push('}');
balance -= 1;
}
repaired
}
fn clean_output(output: &str) -> String {
let without_url_spaces = output
.replace("http ://", "http://")
.replace("https ://", "https://")
.replace(" : / /", "://");
without_url_spaces
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn validate_sample(sample: &EnglishSample) -> Result<()> {
let instruction_lower = sample.instruction.to_lowercase();
let forbidden_phrases = ["structured learning sample", "valid json format"];
for forbidden in forbidden_phrases {
if instruction_lower.contains(forbidden) {
return Err(anyhow!(
"Parsed instruction contains forbidden meta-language: '{}'",
forbidden
));
}
}
if sample.output.trim().is_empty() {
return Err(anyhow!(
"Parsed sample output is empty. The model must produce a direct answer or summary."
));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchEngine {
Dolphin,
DuckDuckGo,
Deepseek,
Qwen,
Grok,
}
impl std::fmt::Display for SearchEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
SearchEngine::Dolphin => "Dolphin Chat",
SearchEngine::DuckDuckGo => "DuckDuckGo",
SearchEngine::Deepseek => "Deepseek",
SearchEngine::Qwen => "Qwen",
SearchEngine::Grok => "Grok",
}
)
}
}
impl SearchEngine {
pub fn search_url(&self, query: &str) -> String {
match self {
SearchEngine::Dolphin => format!("https://chat.dphn.ai/?q={}", encode(query)),
SearchEngine::DuckDuckGo => {
format!("https://html.duckduckgo.com/html/?q={}", encode(query))
}
SearchEngine::Deepseek => format!("https://deepseek.ai/search?q={}", encode(query)),
SearchEngine::Qwen => format!("https://qwen.ai/search?q={}", encode(query)),
SearchEngine::Grok => format!("https://grok.com/search?q={}", encode(query)),
}
}
}
fn create_browser_client() -> Result<Client, reqwest::Error> {
let mut headers = header::HeaderMap::new();
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static(
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
),
);
headers.insert(
header::ACCEPT_LANGUAGE,
header::HeaderValue::from_static("en-US,en;q=0.9"),
);
headers.insert(
header::CONNECTION,
header::HeaderValue::from_static("keep-alive"),
);
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"),
);
Client::builder().default_headers(headers).build()
}
pub async fn web_search(query: &str, engine: SearchEngine) -> Result<String> {
let url = engine.search_url(query);
let client = create_browser_client()?;
let body = client
.get(&url)
.header(header::REFERER, "https://chat.dphn.ai/")
.send()
.await?
.error_for_status()?
.text()
.await?;
let doc = Html::parse_document(&body);
let snippets: Vec<String> = match engine {
SearchEngine::Dolphin => {
let selector = Selector::parse("p, div, span").unwrap();
let mut snippets: Vec<String> = doc
.select(&selector)
.filter_map(|el| {
let text = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
if text.len() >= 20 && text.len() <= 300 {
Some(text)
} else {
None
}
})
.take(5)
.collect();
if snippets.is_empty() {
let body_text = doc.root_element().text().collect::<Vec<_>>().join(" ");
snippets = body_text
.split_terminator(|c| c == '.' || c == '!' || c == '?')
.map(str::trim)
.filter(|text| text.len() >= 20 && text.len() <= 300)
.take(5)
.map(String::from)
.collect();
}
snippets
}
SearchEngine::DuckDuckGo => {
let selector = Selector::parse(".result__snippet").unwrap();
doc.select(&selector)
.take(5)
.map(|el| el.inner_html())
.collect()
}
SearchEngine::Deepseek => {
let selector = Selector::parse(".snippet, .search-result, .result, p").unwrap();
doc.select(&selector)
.filter_map(|el| {
let text = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
if text.len() >= 20 && text.len() <= 300 {
Some(text)
} else {
None
}
})
.take(5)
.collect()
}
SearchEngine::Qwen => {
let selector = Selector::parse(".search-result, .result, p").unwrap();
doc.select(&selector)
.filter_map(|el| {
let text = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
if text.len() >= 20 && text.len() <= 300 {
Some(text)
} else {
None
}
})
.take(5)
.collect()
}
SearchEngine::Grok => {
let selector = Selector::parse(".search-result, .result, p").unwrap();
doc.select(&selector)
.filter_map(|el| {
let text = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
if text.len() >= 20 && text.len() <= 300 {
Some(text)
} else {
None
}
})
.take(5)
.collect()
}
};
if snippets.is_empty() {
return Err(anyhow!(
"{} returned no usable results from {}",
engine,
url
));
}
Ok(format!("{}\n\nSearch page: {}", snippets.join(" "), url))
}
/// Use LLM to convert search result into a structured EnglishSample
pub async fn derive_sample_from_search(
query: &str,
snippets: &str,
ollama: &Ollama,
) -> Result<EnglishSample> {
let prompt = format!(
"Convert the following search result into a structured learning sample in JSON format.\n\
Use this exact schema:\n\
{{\"type\": \"instruction_following\", \"instruction\": \"{}\", \"input\": \"\", \"reasoning\": \"...\", \"output\": \"...\"}}\n\n\
Query: {}\nSearch result: {}\n\nOnly output valid JSON, no extra text. Fill in the reasoning and output based on the search result. The instruction should be the query itself or a rephrased version. The type is always \"instruction_following\".",
query, query, snippets
);
let req = ChatMessageRequest::new("sadiq-bd/llama3.2-3b-uncensored".to_string(), vec![ChatMessage::user(prompt)])
.options(
ModelOptions::default()
.num_ctx(8192)
.num_thread(8)
.num_predict(1024)
.temperature(0.85),
)
.keep_alive(KeepAlive::Indefinitely);
let resp = ollama.send_chat_messages(req).await?;
let json_str = resp.message.content;
let sample: EnglishSample = serde_json::from_str(&json_str)?;
Ok(sample)
}
/// Convert a large PDF text into a sequence of structured EnglishSample objects.
pub async fn derive_samples_from_pdf_text(
pdf_path: &Path,
text_chunks: &[String],
ollama: &Ollama,
model_name: &str,
) -> Result<Vec<EnglishSample>> {
let mut samples = Vec::new();
let total = text_chunks.len();
for (index, chunk) in text_chunks.iter().enumerate() {
let prompt = format!(
"Extract ONE learning sample from the following text in {}.\n\
Use this exact schema: {{\"type\": \"instruction_following\", \"instruction\": \"...\", \"input\": \"\", \"reasoning\": \"...\", \"output\": \"...\"}}.\n\
The instruction must be a specific technical question or task based *only* on the text.\n\
The output must be a direct answer or summary (no empty strings unless truly absent).\n\
Do NOT include any meta-dialogue, do NOT describe the format, and do NOT output anything except the JSON object.\n\nText:\n{}",
pdf_path.file_name().unwrap_or_default().to_string_lossy(),
chunk
);
let opts = ModelOptions::default()
.num_ctx(8192)
.num_thread(8)
.num_predict(2048)
.temperature(0.85);
let request = GenerationRequest::new(model_name.to_string(), prompt.clone())
.format(FormatType::Json)
.options(opts.clone());
let resp = match ollama.generate(request).await {
Ok(r) => r,
Err(e) if model_name == "deepseek-r1:7b" => {
eprintln!(
"⚠️ deepseek-r1:7b failed, falling back to qwen2.5-coder:1.5b. Error: {}",
e
);
let fallback_req = GenerationRequest::new("qwen2.5-coder:1.5b".to_string(), prompt.clone())
.format(FormatType::Json)
.options(opts.clone());
match ollama.generate(fallback_req).await {
Ok(r) => r,
Err(_) => {
eprintln!(
"⚠️ qwen2.5-coder:1.5b also failed; falling back to sadiq-bd/llama3.2-3b-uncensored."
);
let fallback_req = GenerationRequest::new("sadiq-bd/llama3.2-3b-uncensored".to_string(), prompt)
.format(FormatType::Json)
.options(opts);
ollama.generate(fallback_req).await?
}
}
}
Err(e) if model_name == "qwen2.5-coder:1.5b" => {
eprintln!(
"⚠️ qwen2.5-coder:1.5b failed, falling back to sadiq-bd/llama3.2-3b-uncensored. Error: {}",
e
);
let fallback_req = GenerationRequest::new("sadiq-bd/llama3.2-3b-uncensored".to_string(), prompt)
.format(FormatType::Json)
.options(opts);
ollama.generate(fallback_req).await?
}
Err(e) => return Err(e.into()),
};
let raw_response = resp.response;
let mut json_candidate = extract_json_from_response(&raw_response)
.unwrap_or_else(|| raw_response.trim().to_string());
if !is_json_braces_balanced(&json_candidate) {
json_candidate = repair_json_braces(&json_candidate);
}
let sample: EnglishSample = match serde_json::from_str::<EnglishSample>(&json_candidate) {
Ok(mut s) => {
s.output = clean_output(&s.output);
s
}
Err(err) => {
let repaired_candidate = repair_json_braces(&json_candidate);
if repaired_candidate != json_candidate {
serde_json::from_str(&repaired_candidate).map_err(|second_err| {
anyhow::anyhow!(
"Failed to parse JSON from PDF chunk {} of {}: {}\nRetry parse error: {}\nCandidate: {}\nRepaired: {}\nRaw response: {}",
index + 1,
total,
err,
second_err,
json_candidate,
repaired_candidate,
raw_response
)
})?
} else if let Some(cleaned) = extract_json_from_response(&raw_response) {
serde_json::from_str(&cleaned).map_err(|second_err| {
anyhow::anyhow!(
"Failed to parse JSON from PDF chunk {} of {}: {}\nRetry parse error: {}\nCleaned response: {}\nRaw response: {}",
index + 1,
total,
err,
second_err,
cleaned,
raw_response
)
})?
} else {
return Err(anyhow::anyhow!(
"Failed to parse JSON from PDF chunk {} of {}.\nRaw response:\n{}",
index + 1,
total,
raw_response
));
}
}
};
validate_sample(&sample).map_err(|validation_err| {
anyhow::anyhow!(
"Validation failed for PDF chunk {} of {}: {}\nCandidate: {}\nRaw response: {}",
index + 1,
total,
validation_err,
json_candidate,
raw_response
)
})?;
samples.push(sample);
}
Ok(samples)
}
/// Append a new sample to the dynamic knowledge file
pub fn append_sample_to_jsonl(sample: &EnglishSample, path: &str) -> Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
let line = serde_json::to_string(sample)?;
writeln!(file, "{}", line)?;
Ok(())
}
/// Compute embedding for a sample (so we can add it to inmemory vectors)
pub async fn compute_sample_embedding(
sample: &EnglishSample,
embed_model: &str,
ollama: &Ollama,
) -> Result<Vec<f32>> {
let text = format!(
"Instruction: {}\nOutput: {}",
sample.instruction, sample.output
);
let req =
GenerateEmbeddingsRequest::new(embed_model.to_string(), EmbeddingsInput::Single(text))
.keep_alive(KeepAlive::Indefinitely);
let resp = ollama.generate_embeddings(req).await?;
Ok(resp.embeddings.into_iter().next().unwrap_or_default())
}