use crate::config::{Config, SourceConfig};
use std::fmt::Write;
pub struct CopilotOutput {
pub instructions: String,
pub topics: Vec<GeneratedTopic>,
pub guide: String,
}
pub struct GeneratedTopic {
pub filename: String,
pub yaml: String,
}
pub fn generate(config: &Config) -> CopilotOutput {
let mut topics = Vec::new();
for source in &config.sources {
let index_name = source.index();
let endpoint = &config.azure.endpoint;
let semantic_config = format!("{index_name}-semantic-config");
match source {
SourceConfig::Jira(jira) => {
topics.push(GeneratedTopic {
filename: format!("{}-search.mcs.yaml", jira.name),
yaml: jira_topic(endpoint, index_name, &semantic_config),
});
}
SourceConfig::Confluence(conf) => {
topics.push(GeneratedTopic {
filename: format!("{}-search.mcs.yaml", conf.name),
yaml: confluence_topic(endpoint, index_name, &semantic_config),
});
}
}
}
let instructions = agent_instructions(config);
let guide = usage_guide(config, &topics);
CopilotOutput {
instructions,
topics,
guide,
}
}
fn jira_topic(endpoint: &str, index_name: &str, semantic_config: &str) -> String {
format!(
r#"# OnKnowledgeRequested topic for Jira issues index "{index_name}"
# Generated by quelch — paste into Copilot Studio code editor
#
# This topic fires when the agent needs to search for knowledge.
# It queries Azure AI Search with semantic search + the full set of
# filterable/facetable fields that quelch indexes.
kind: AdaptiveDialog
beginDialog:
kind: OnKnowledgeRequested
id: main
intent: {{}}
actions:
- kind: HttpRequestAction
id: jiraSearch
method: POST
url: "{endpoint}/indexes('{index_name}')/docs/search.post.search?api-version=2024-07-01"
headers:
Content-Type: application/json
api-key: "{{{{System.Env.AZURE_SEARCH_API_KEY}}}}"
body:
kind: Json
value:
search: =System.SearchQuery
queryType: semantic
semanticConfiguration: "{semantic_config}"
select: "summary,content,url,assignee,project,status,priority,issue_type,issue_key,labels,created_at,updated_at"
top: 15
searchMode: any
count: true
captions: extractive
answers: extractive
response: Topic.searchResults
responseSchema:
kind: Record
properties:
"@@odata.count":
type: Number
value:
type:
kind: Table
properties:
summary: String
content: String
url: String
assignee: String
project: String
status: String
priority: String
issue_type: String
issue_key: String
labels: String
created_at: String
updated_at: String
- kind: SetVariable
id: setSearchResults
variable: System.SearchResults
value: |-
=ForAll(Topic.searchResults.value,
{{
Content: issue_key & " [" & status & "] " & summary & " (assigned to: " & assignee & ", project: " & project & ", priority: " & priority & ")" & Char(10) & content,
ContentLocation: url,
Title: issue_key & " — " & summary
}})
inputType: {{}}
outputType: {{}}
"#
)
}
fn confluence_topic(endpoint: &str, index_name: &str, semantic_config: &str) -> String {
format!(
r#"# OnKnowledgeRequested topic for Confluence pages index "{index_name}"
# Generated by quelch — paste into Copilot Studio code editor
#
# This topic fires when the agent needs to search for knowledge.
# It queries Azure AI Search with semantic search over chunked
# Confluence page content.
kind: AdaptiveDialog
beginDialog:
kind: OnKnowledgeRequested
id: main
intent: {{}}
actions:
- kind: HttpRequestAction
id: confluenceSearch
method: POST
url: "{endpoint}/indexes('{index_name}')/docs/search.post.search?api-version=2024-07-01"
headers:
Content-Type: application/json
api-key: "{{{{System.Env.AZURE_SEARCH_API_KEY}}}}"
body:
kind: Json
value:
search: =System.SearchQuery
queryType: semantic
semanticConfiguration: "{semantic_config}"
select: "page_title,content,url,space_key,author,chunk_heading,labels,created_at,updated_at"
top: 15
searchMode: any
captions: extractive
answers: extractive
response: Topic.searchResults
responseSchema:
kind: Record
properties:
value:
type:
kind: Table
properties:
page_title: String
content: String
url: String
space_key: String
author: String
chunk_heading: String
labels: String
created_at: String
updated_at: String
- kind: SetVariable
id: setSearchResults
variable: System.SearchResults
value: |-
=ForAll(Topic.searchResults.value,
{{
Content: "[" & space_key & "] " & page_title & If(!IsBlank(chunk_heading), " > " & chunk_heading, "") & Char(10) & content,
ContentLocation: url,
Title: page_title & If(!IsBlank(chunk_heading), " — " & chunk_heading, "")
}})
inputType: {{}}
outputType: {{}}
"#
)
}
fn agent_instructions(config: &Config) -> String {
let mut out = String::new();
writeln!(out, "# Agent Instructions").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"You are a knowledge assistant with access to data from the following sources:"
)
.unwrap();
writeln!(out).unwrap();
let jira_sources: Vec<_> = config
.sources
.iter()
.filter_map(|s| match s {
SourceConfig::Jira(j) => Some(j),
_ => None,
})
.collect();
let confluence_sources: Vec<_> = config
.sources
.iter()
.filter_map(|s| match s {
SourceConfig::Confluence(c) => Some(c),
_ => None,
})
.collect();
if !jira_sources.is_empty() {
writeln!(out, "## Jira Issues").unwrap();
writeln!(out).unwrap();
for j in &jira_sources {
let projects = j.projects.join(", ");
writeln!(out, "- **{}**: Issues from projects: {}", j.name, projects).unwrap();
}
writeln!(out).unwrap();
writeln!(
out,
"Each Jira issue has the following fields that you can reference in your answers:"
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "| Field | Description | Example |").unwrap();
writeln!(out, "|-------|-------------|---------|").unwrap();
writeln!(out, "| issue_key | The Jira issue identifier | PROJ-123 |").unwrap();
writeln!(
out,
"| summary | The issue title/summary | Fix login button on mobile |"
)
.unwrap();
writeln!(
out,
"| description | Detailed issue description | Full text of the issue body |"
)
.unwrap();
writeln!(out, "| status | Current status | Open, In Progress, Done |").unwrap();
writeln!(
out,
"| status_category | Status category | To Do, In Progress, Done |"
)
.unwrap();
writeln!(
out,
"| priority | Issue priority | Critical, High, Medium, Low |"
)
.unwrap();
writeln!(
out,
"| issue_type | Type of issue | Bug, Story, Task, Epic |"
)
.unwrap();
writeln!(
out,
"| assignee | Person assigned to the issue | John Doe |"
)
.unwrap();
writeln!(
out,
"| reporter | Person who created the issue | Jane Smith |"
)
.unwrap();
writeln!(out, "| project | Project key | PROJ |").unwrap();
writeln!(
out,
"| labels | Tags/labels on the issue | backend, urgent |"
)
.unwrap();
writeln!(
out,
"| comments | Discussion comments on the issue | Free text |"
)
.unwrap();
writeln!(out, "| created_at | When the issue was created | Date |").unwrap();
writeln!(
out,
"| updated_at | When the issue was last updated | Date |"
)
.unwrap();
writeln!(out, "| url | Link to the issue in Jira | URL |").unwrap();
writeln!(out).unwrap();
}
if !confluence_sources.is_empty() {
writeln!(out, "## Confluence Pages").unwrap();
writeln!(out).unwrap();
for c in &confluence_sources {
let spaces = c.spaces.join(", ");
writeln!(out, "- **{}**: Pages from spaces: {}", c.name, spaces).unwrap();
}
writeln!(out).unwrap();
writeln!(
out,
"Each Confluence page is split into chunks by heading. Fields available:"
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "| Field | Description |").unwrap();
writeln!(out, "|-------|-------------|").unwrap();
writeln!(out, "| page_title | The page title |").unwrap();
writeln!(out, "| chunk_heading | Section heading within the page |").unwrap();
writeln!(out, "| body | The text content of this section |").unwrap();
writeln!(out, "| space_key | Confluence space key |").unwrap();
writeln!(out, "| author | Page author |").unwrap();
writeln!(out, "| labels | Page labels/tags |").unwrap();
writeln!(out, "| url | Link to the page in Confluence |").unwrap();
writeln!(out).unwrap();
}
writeln!(out, "## How to Answer Questions").unwrap();
writeln!(out).unwrap();
if !jira_sources.is_empty() {
writeln!(out, "### Jira Questions").unwrap();
writeln!(out).unwrap();
writeln!(out, "When answering questions about Jira issues:").unwrap();
writeln!(out).unwrap();
writeln!(out, "- **Always include the issue key** (e.g., PROJ-123) and a link to the issue when referencing specific issues.").unwrap();
writeln!(out, "- **Include relevant metadata** such as status, assignee, and priority when listing issues.").unwrap();
writeln!(out, "- When asked about issues assigned to a person, search for their name and present matching results.").unwrap();
writeln!(out, "- When asked to count or list \"all\" issues, be transparent that you can only search and return the most relevant results from the index — you may not have visibility into every issue. State the number of results you found and note that there may be more.").unwrap();
writeln!(out, "- When asked about a specific project, mention the project key in your search to improve relevance.").unwrap();
writeln!(
out,
"- Format issue lists as tables when presenting multiple issues."
)
.unwrap();
writeln!(out).unwrap();
}
if !confluence_sources.is_empty() {
writeln!(out, "### Confluence Questions").unwrap();
writeln!(out).unwrap();
writeln!(out, "When answering questions about Confluence content:").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"- **Always link to the source page** so the user can read the full context."
)
.unwrap();
writeln!(
out,
"- Reference the page title and section heading when quoting content."
)
.unwrap();
writeln!(out, "- If multiple chunks from the same page are relevant, synthesize them into a coherent answer rather than repeating page references.").unwrap();
writeln!(out).unwrap();
}
writeln!(out, "### General Guidelines").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"- If you cannot find relevant information, say so clearly rather than guessing."
)
.unwrap();
writeln!(out, "- Always cite your sources with links.").unwrap();
writeln!(
out,
"- Be concise but complete. Prefer tables for structured data and prose for explanations."
)
.unwrap();
out
}
fn usage_guide(config: &Config, topics: &[GeneratedTopic]) -> String {
let mut out = String::new();
writeln!(out, "# Copilot Studio Agent Setup Guide").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"Generated by `quelch generate-agent` based on your quelch.yaml configuration."
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "## What Was Generated").unwrap();
writeln!(out).unwrap();
writeln!(out, "| File | Purpose |").unwrap();
writeln!(out, "|------|---------|").unwrap();
writeln!(
out,
"| `agent-instructions.md` | System prompt / agent instructions — paste into your agent's Instructions field |"
)
.unwrap();
for topic in topics {
writeln!(
out,
"| `{}` | OnKnowledgeRequested topic — custom search logic for this index |",
topic.filename
)
.unwrap();
}
writeln!(out, "| `guide.md` | This file — setup instructions |").unwrap();
writeln!(out).unwrap();
writeln!(out, "## Setup Steps").unwrap();
writeln!(out).unwrap();
writeln!(out, "### 1. Create or Open Your Agent in Copilot Studio").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"Go to [Copilot Studio](https://copilotstudio.microsoft.com) and create a new agent or open an existing one."
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "### 2. Set the Agent Instructions").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"Copy the contents of `agent-instructions.md` into your agent's **Instructions** field."
)
.unwrap();
writeln!(out).unwrap();
writeln!(
out,
"This tells the agent what data it has access to and how to format answers."
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "### 3. Add the OnKnowledgeRequested Topics").unwrap();
writeln!(out).unwrap();
writeln!(out, "For each generated `.mcs.yaml` file:").unwrap();
writeln!(out).unwrap();
writeln!(out, "1. In Copilot Studio, go to **Topics**").unwrap();
writeln!(out, "2. Click **Add** > **Topic** > **From blank**").unwrap();
writeln!(
out,
"3. Click the ellipsis (**...**) in the top-right and select **Open code editor**"
)
.unwrap();
writeln!(
out,
"4. Replace the default YAML with the contents of the generated file"
)
.unwrap();
writeln!(out, "5. Save the topic").unwrap();
writeln!(out).unwrap();
writeln!(out, "### 4. Configure Authentication").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"The generated topics reference `{{{{System.Env.AZURE_SEARCH_API_KEY}}}}` for the API key."
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "You have two options to provide the API key:").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"**Option A: Environment variable** — Set `AZURE_SEARCH_API_KEY` as an environment variable in your Copilot Studio environment."
)
.unwrap();
writeln!(out).unwrap();
writeln!(
out,
"**Option B: Hardcode the key** — Replace `{{{{System.Env.AZURE_SEARCH_API_KEY}}}}` in the YAML with your actual Azure AI Search query key. Less secure but simpler for testing."
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "Use a **query key** (read-only), not your admin key.").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"### 5. Remove Built-in Azure AI Search Knowledge Source"
)
.unwrap();
writeln!(out).unwrap();
writeln!(
out,
"If you previously added your Azure AI Search index as a built-in knowledge source (via the Knowledge page), **remove it**. The OnKnowledgeRequested topics replace the built-in integration with full query control. Having both would cause duplicate or conflicting results."
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "### 6. Publish and Test").unwrap();
writeln!(out).unwrap();
writeln!(out, "Publish your agent and test with queries like:").unwrap();
writeln!(out).unwrap();
let has_jira = config
.sources
.iter()
.any(|s| matches!(s, SourceConfig::Jira(_)));
let has_confluence = config
.sources
.iter()
.any(|s| matches!(s, SourceConfig::Confluence(_)));
if has_jira {
writeln!(out, "- \"Find Jira issues about wifi problems\"").unwrap();
writeln!(out, "- \"Show me issues assigned to John Doe\"").unwrap();
writeln!(
out,
"- \"What high-priority bugs are open in the PROJ project?\""
)
.unwrap();
writeln!(out, "- \"Summarize recent activity in the ENG project\"").unwrap();
}
if has_confluence {
writeln!(out, "- \"How do I set up the development environment?\"").unwrap();
writeln!(out, "- \"What does our deployment process look like?\"").unwrap();
writeln!(out, "- \"Find documentation about authentication\"").unwrap();
}
writeln!(out).unwrap();
writeln!(out, "## How It Works").unwrap();
writeln!(out).unwrap();
writeln!(out, "The `OnKnowledgeRequested` trigger fires when the agent's LLM decides it needs to search for information. The topic then:").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"1. Takes `System.SearchQuery` (a context-aware rewrite of the user's question)"
)
.unwrap();
writeln!(
out,
"2. Sends it to Azure AI Search using semantic search with your configured index"
)
.unwrap();
writeln!(
out,
"3. Returns up to 15 results with structured metadata (issue key, status, assignee, etc.)"
)
.unwrap();
writeln!(
out,
"4. The agent's LLM uses these results to generate a grounded answer with citations"
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "The key advantage over the built-in Azure AI Search knowledge source is that the `Content` field in the results includes structured metadata (assignee, status, project, priority), which helps the LLM answer questions like \"who is assigned to this?\" or \"what's the status?\" even though it's using semantic search under the hood.").unwrap();
writeln!(out).unwrap();
writeln!(out, "## Customization").unwrap();
writeln!(out).unwrap();
writeln!(out, "### Adding OData Filters").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"You can add a `filter` field to the search body to scope results. For example:"
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "```yaml").unwrap();
writeln!(out, "body:").unwrap();
writeln!(out, " kind: Json").unwrap();
writeln!(out, " value:").unwrap();
writeln!(out, " search: =System.SearchQuery").unwrap();
writeln!(
out,
" filter: \"project eq 'PROJ' and status_category ne 'Done'\""
)
.unwrap();
writeln!(out, " # ... rest of search parameters").unwrap();
writeln!(out, "```").unwrap();
writeln!(out).unwrap();
writeln!(out, "Available filter fields for Jira:").unwrap();
writeln!(out).unwrap();
writeln!(out, "| Field | Type | Example Filter |").unwrap();
writeln!(out, "|-------|------|---------------|").unwrap();
writeln!(out, "| assignee | string | `assignee eq 'John Doe'` |").unwrap();
writeln!(out, "| project | string | `project eq 'PROJ'` |").unwrap();
writeln!(out, "| status | string | `status eq 'In Progress'` |").unwrap();
writeln!(
out,
"| status_category | string | `status_category eq 'Done'` |"
)
.unwrap();
writeln!(out, "| priority | string | `priority eq 'High'` |").unwrap();
writeln!(out, "| issue_type | string | `issue_type eq 'Bug'` |").unwrap();
writeln!(
out,
"| labels | string collection | `labels/any(l: l eq 'urgent')` |"
)
.unwrap();
writeln!(
out,
"| created_at | datetime | `created_at ge 2024-01-01T00:00:00Z` |"
)
.unwrap();
writeln!(
out,
"| updated_at | datetime | `updated_at ge 2024-01-01T00:00:00Z` |"
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "### Changing Result Count").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"The `top` parameter controls how many results are returned. The maximum useful value is **15** — Copilot Studio uses at most 15 snippets for answer generation."
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "## References").unwrap();
writeln!(out).unwrap();
writeln!(out, "- [Copilot Studio: Custom Knowledge Sources](https://learn.microsoft.com/en-us/microsoft-copilot-studio/guidance/custom-knowledge-sources)").unwrap();
writeln!(out, "- [Azure AI Search POST Search API](https://learn.microsoft.com/en-us/rest/api/searchservice/documents/search-post)").unwrap();
writeln!(out, "- [OData Filter Syntax](https://learn.microsoft.com/en-us/azure/search/search-query-odata-filter)").unwrap();
writeln!(out, "- [Copilot Studio YAML Code Editor](https://learn.microsoft.com/en-us/microsoft-copilot-studio/guidance/topics-code-editor)").unwrap();
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::*;
fn test_config() -> Config {
Config {
azure: AzureConfig {
endpoint: "https://my-search.search.windows.net".to_string(),
api_key: "test-key".to_string(),
},
sources: vec![
SourceConfig::Jira(JiraSourceConfig {
name: "my-jira".to_string(),
url: "https://company.atlassian.net".to_string(),
auth: AuthConfig::Cloud {
email: "user@test.com".to_string(),
api_token: "token".to_string(),
},
projects: vec!["PROJ".to_string(), "ENG".to_string()],
index: "jira-issues".to_string(),
}),
SourceConfig::Confluence(ConfluenceSourceConfig {
name: "my-confluence".to_string(),
url: "https://company.atlassian.net/wiki".to_string(),
auth: AuthConfig::Cloud {
email: "user@test.com".to_string(),
api_token: "token".to_string(),
},
spaces: vec!["ENG".to_string(), "DOCS".to_string()],
index: "confluence-pages".to_string(),
}),
],
sync: SyncConfig::default(),
}
}
#[test]
fn generates_topics_for_each_source() {
let output = generate(&test_config());
assert_eq!(output.topics.len(), 2);
assert_eq!(output.topics[0].filename, "my-jira-search.mcs.yaml");
assert_eq!(output.topics[1].filename, "my-confluence-search.mcs.yaml");
}
#[test]
fn jira_topic_contains_endpoint_and_index() {
let output = generate(&test_config());
let yaml = &output.topics[0].yaml;
assert!(yaml.contains("my-search.search.windows.net"));
assert!(yaml.contains("jira-issues"));
assert!(yaml.contains("jira-issues-semantic-config"));
}
#[test]
fn confluence_topic_contains_endpoint_and_index() {
let output = generate(&test_config());
let yaml = &output.topics[1].yaml;
assert!(yaml.contains("my-search.search.windows.net"));
assert!(yaml.contains("confluence-pages"));
assert!(yaml.contains("confluence-pages-semantic-config"));
}
#[test]
fn instructions_mention_configured_projects() {
let output = generate(&test_config());
assert!(output.instructions.contains("PROJ"));
assert!(output.instructions.contains("ENG"));
}
#[test]
fn instructions_mention_configured_spaces() {
let output = generate(&test_config());
assert!(output.instructions.contains("ENG"));
assert!(output.instructions.contains("DOCS"));
}
#[test]
fn guide_lists_generated_files() {
let output = generate(&test_config());
assert!(output.guide.contains("my-jira-search.mcs.yaml"));
assert!(output.guide.contains("my-confluence-search.mcs.yaml"));
assert!(output.guide.contains("agent-instructions.md"));
}
#[test]
fn jira_only_config() {
let config = Config {
azure: AzureConfig {
endpoint: "https://test.search.windows.net".to_string(),
api_key: "key".to_string(),
},
sources: vec![SourceConfig::Jira(JiraSourceConfig {
name: "jira".to_string(),
url: "https://jira.example.com".to_string(),
auth: AuthConfig::DataCenter {
pat: "pat".to_string(),
},
projects: vec!["HR".to_string()],
index: "jira-idx".to_string(),
})],
sync: SyncConfig::default(),
};
let output = generate(&config);
assert_eq!(output.topics.len(), 1);
assert!(output.instructions.contains("Jira Issues"));
assert!(!output.instructions.contains("Confluence Pages"));
}
}