#![allow(private_interfaces)]
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::access::RepoFilter;
use crate::search::context::{BridgeMode, ContentMode, ContextAssembler, ContextConfig};
use crate::tags::{build_tag_exclude_filter, build_tag_include_filter};
use super::{
bad_request, find_matching_bundles, internal_error, open_metadata_store, open_vector_store,
to_context_file, to_context_summary, AppState, BundleMatchOutput, ContextBudgetInfo,
ContextFileOutput, ContextSummaryOutput, ErrorBody,
};
#[derive(Deserialize)]
pub(super) struct ContextParams {
q: String,
budget: Option<usize>,
depth: Option<u32>,
max_coupled: Option<usize>,
limit: Option<usize>,
coupling_threshold: Option<f32>,
repo: Option<String>,
group: Option<String>,
role: Option<String>,
tag: Option<String>,
exclude_tag: Option<String>,
repo_affinity: Option<String>,
semantic_weight: Option<f32>,
doc_demotion: Option<f32>,
recency_weight: Option<f32>,
bundle: Option<String>,
}
#[derive(Serialize)]
pub(super) struct ContextResponse {
query: String,
budget: ContextBudgetInfo,
files: Vec<ContextFileOutput>,
summary: ContextSummaryOutput,
#[serde(skip_serializing_if = "Vec::is_empty")]
bundles: Vec<BundleMatchOutput>,
}
pub(super) async fn context(
State(state): State<Arc<AppState>>,
Query(params): Query<ContextParams>,
) -> Result<Json<ContextResponse>, (StatusCode, Json<ErrorBody>)> {
let start = std::time::Instant::now();
let vector_store = open_vector_store(&state).await.map_err(internal_error)?;
let stats = vector_store
.get_stats(None)
.await
.map_err(|e| internal_error(e.into()))?;
if stats.total_chunks == 0 {
return Ok(Json(ContextResponse {
query: params.q,
budget: ContextBudgetInfo {
max_lines: params.budget.unwrap_or(500),
used_lines: 0,
},
files: vec![],
summary: ContextSummaryOutput {
total_files: 0,
total_chunks: 0,
direct_hits: 0,
coupled_additions: 0,
bridged_additions: 0,
source_files: 0,
doc_files: 0,
top_semantic_score: 0.0,
intent: None,
gate_boost: None,
},
bundles: vec![],
}));
}
let metadata_store = open_metadata_store(&state).map_err(internal_error)?;
let embedder = state.get_embedder().await.map_err(internal_error)?.clone();
let mut tag_filters: Vec<String> = Vec::new();
if let Some(ref tags) = params.tag {
let tag_list: Vec<String> = tags
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
if !tag_list.is_empty() {
tag_filters.push(build_tag_include_filter(&tag_list));
}
}
if let Some(ref tags) = params.exclude_tag {
let tag_list: Vec<String> = tags
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
if !tag_list.is_empty() {
tag_filters.push(build_tag_exclude_filter(&tag_list));
}
}
if let Some(ref bundle_name) = params.bundle {
if let Some(bundle_filter) = state.tags_config.build_bundle_file_filter(bundle_name) {
tag_filters.push(bundle_filter);
}
}
let extra_filter = if tag_filters.is_empty() {
None
} else {
Some(tag_filters.join(" AND "))
};
let feedback_scores = super::feedback::open_feedback_store(&state)
.ok()
.and_then(|fb| fb.file_feedback_scores(¶ms.q, 0.15).ok())
.filter(|m| !m.is_empty());
let context_config = ContextConfig {
budget_lines: params.budget.unwrap_or(500),
budget_unit: state.config.context.budget_unit,
depth: params.depth.unwrap_or(1),
max_coupled: params.max_coupled.unwrap_or(3),
coupling_threshold: params
.coupling_threshold
.unwrap_or(state.config.context.coupling_threshold),
semantic_weight: params
.semantic_weight
.unwrap_or(state.config.search.semantic_weight),
content_mode: ContentMode::Full,
search_limit: params.limit.unwrap_or(20),
doc_demotion: params
.doc_demotion
.unwrap_or(state.config.search.doc_demotion),
recency_half_life_days: state.config.search.recency_half_life_days,
recency_weight: params
.recency_weight
.unwrap_or(state.config.search.recency_weight),
rrf_k: state.config.search.rrf_k,
bridge_mode: BridgeMode::default(),
bridge_boost_factor: state.config.context.bridge_boost_factor,
extra_filter,
tags_config: Some(state.tags_config.clone()),
role: params.role.clone(),
file_type_rules: state.config.file_types.clone(),
repo_affinity: params.repo_affinity.clone(),
repo_affinity_boost: state.config.hooks.repo_affinity_boost,
max_bridged_files: state.config.context.max_bridged_files,
max_bridged_chunks_per_file: state.config.context.max_bridged_chunks_per_file,
knowledge_budget_pct: state.config.context.knowledge_budget_pct,
neighbor_budget_pct: state.config.context.neighbor_budget_pct,
knowledge_max_hops: state.config.context.knowledge_max_hops,
feedback_boost_max: state.config.feedback.boost_max,
feedback_boost_weight: state.config.feedback.boost_weight,
repo_path_prefix: state.config.server.repo_path_prefix.clone(),
feedback_scores,
ppr_weight: state.config.search.ppr_weight,
repo_root: Some(state.repo_root.clone()),
..ContextConfig::default()
};
let mut assembler =
ContextAssembler::new(embedder, vector_store, metadata_store, context_config);
#[cfg(feature = "knowledge")]
if state.config.search.ppr_weight > 0.0 {
let quipu_config = quipu::QuipuConfig::load(&state.repo_root);
let qpath = if quipu_config.store_path.is_relative() {
state.repo_root.join(&quipu_config.store_path)
} else {
quipu_config.store_path.clone()
};
match quipu::Store::open(qpath.to_string_lossy().as_ref()) {
Ok(store) => assembler = assembler.with_quipu_store(store),
Err(e) => tracing::warn!("PPR enabled but Quipu store unavailable: {}", e),
}
}
let mut bundle = assembler
.assemble(¶ms.q, params.repo.as_deref())
.await
.map_err(internal_error)?;
let access = super::resolve_filter(&state, params.role.as_deref());
bundle.files.retain(|f| access.is_path_allowed(&f.path));
if let Some(ref group_name) = params.group {
let group_repos = state.config.resolve_group(group_name).ok_or_else(|| {
let available: Vec<&str> = state
.config
.groups
.iter()
.map(|g| g.name.as_str())
.collect();
if available.is_empty() {
bad_request(format!(
"Unknown group '{}'. No groups configured.",
group_name
))
} else {
bad_request(format!(
"Unknown group '{}'. Available: {}",
group_name,
available.join(", ")
))
}
})?;
bundle.files.retain(|f| {
let repo = RepoFilter::repo_from_path(&f.path);
group_repos.iter().any(|g| g == repo)
});
}
let file_count = bundle.files.len();
let elapsed = start.elapsed();
log_context(
&bundle.query,
params.repo.as_deref(),
params.role.as_deref(),
file_count,
(bundle.budget.used_lines, bundle.budget.max_lines),
elapsed.as_millis() as u64,
);
let intent = crate::search::intent::classify_intent(¶ms.q);
let adj = crate::search::intent::intent_adjustments(intent);
let mut summary = to_context_summary(&bundle.summary);
summary.intent = Some(format!("{:?}", intent));
summary.gate_boost = Some(adj.gate_boost);
let matched_bundles = find_matching_bundles(&state.tags_config, ¶ms.q);
Ok(Json(ContextResponse {
query: bundle.query,
budget: ContextBudgetInfo {
max_lines: bundle.budget.max_lines,
used_lines: bundle.budget.used_lines,
},
files: bundle.files.iter().map(to_context_file).collect(),
summary,
bundles: matched_bundles,
}))
}
#[derive(Deserialize)]
pub(super) struct ReadChunkParams {
file: String,
start_line: u32,
end_line: u32,
context: Option<u32>,
}
#[derive(Serialize)]
pub(super) struct ReadChunkResponse {
file: String,
start_line: u32,
end_line: u32,
actual_start_line: u32,
actual_end_line: u32,
content: String,
language: String,
}
pub(super) async fn read_chunk(
State(state): State<Arc<AppState>>,
Query(params): Query<ReadChunkParams>,
) -> Result<Json<ReadChunkResponse>, (StatusCode, Json<ErrorBody>)> {
let ctx = params.context.unwrap_or(0);
let (content, actual_start, actual_end) = read_file_lines(
&state.repo_root,
¶ms.file,
params.start_line,
params.end_line,
ctx,
)
.map_err(internal_error)?;
Ok(Json(ReadChunkResponse {
file: params.file.clone(),
start_line: params.start_line,
end_line: params.end_line,
actual_start_line: actual_start,
actual_end_line: actual_end,
content,
language: super::detect_language(¶ms.file),
}))
}
fn read_file_lines(
repo_root: &std::path::Path,
file: &str,
start: u32,
end: u32,
context: u32,
) -> anyhow::Result<(String, u32, u32)> {
let file_path = repo_root.join(file);
if !file_path.exists() {
anyhow::bail!("File not found: {}", file);
}
let content = std::fs::read_to_string(&file_path)?;
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len() as u32;
let actual_start = start.saturating_sub(context).max(1);
let actual_end = (end + context).min(total_lines);
let start_idx = (actual_start - 1) as usize;
let end_idx = actual_end as usize;
let selected = if end_idx <= lines.len() {
lines[start_idx..end_idx].join("\n")
} else {
lines[start_idx..].join("\n")
};
Ok((selected, actual_start, actual_end))
}
pub(super) fn log_context(
query: &str,
repo: Option<&str>,
role: Option<&str>,
files: usize,
(budget_used, budget_max): (usize, usize),
duration_ms: u64,
) {
let query_sha = super::query_sha(query);
tracing::debug!(query = %query, query_sha = %query_sha, "context query");
tracing::info!(
query_len = query.chars().count(),
query_sha = %query_sha,
repo = repo.unwrap_or("-"),
role = role.unwrap_or("-"),
files = files,
budget_used = budget_used,
budget_max = budget_max,
duration_ms = duration_ms,
"context"
);
}