Skip to content

Commit 8f269e6

Browse files
committed
automatically generate updates blog post
Makes use of AWS Bedrock to summarize posted comments.
1 parent cddaafa commit 8f269e6

File tree

8 files changed

+946
-76
lines changed

8 files changed

+946
-76
lines changed

Cargo.lock

Lines changed: 644 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mdbook-goals/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ rust_team_data = { git = "https://github.com/rust-lang/team" }
1818
lazy_static = "1.5.0"
1919
progress_bar = "1.0.5"
2020
chrono = "0.4.38"
21+
aws-config = "1.5.8"
22+
aws-sdk-bedrock = "1.57.0"
23+
aws-sdk-bedrockruntime = "1.55.0"
24+
tokio = { version = "1.41.0", features = ["full"] }

mdbook-goals/src/gh/issues.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use crate::util::comma;
1010

1111
use super::{issue_id::Repository, labels::GhLabel};
1212

13-
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
13+
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
1414
pub struct ExistingGithubIssue {
1515
pub number: u64,
1616
/// Just github username, no `@`
@@ -21,7 +21,7 @@ pub struct ExistingGithubIssue {
2121
pub labels: Vec<GhLabel>,
2222
}
2323

24-
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
24+
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
2525
pub struct ExistingGithubComment {
2626
/// Just github username, no `@`
2727
pub author: String,
@@ -61,7 +61,7 @@ struct ExistingGithubAuthorJson {
6161
login: String,
6262
}
6363

64-
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
64+
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
6565
#[serde(rename_all = "UPPERCASE")]
6666
pub enum ExistingIssueState {
6767
Open,

mdbook-goals/src/gh/labels.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
44

55
use super::issue_id::Repository;
66

7-
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
7+
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
88
pub struct GhLabel {
99
pub name: String,
1010
pub color: String,

mdbook-goals/src/json.rs

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,7 @@ pub(super) fn generate_json(
3232
issues: issues
3333
.into_iter()
3434
.map(|(title, issue)| {
35-
let progress = match checkboxes(&issue) {
36-
Ok(pair) => pair,
37-
Err(e) => Progress::Error {
38-
message: e.to_string(),
39-
},
40-
};
35+
let progress = checkboxes(&issue);
4136
TrackingIssue {
4237
number: issue.number,
4338
title,
@@ -98,10 +93,12 @@ struct TrackingIssue {
9893
}
9994

10095
#[derive(Serialize)]
101-
enum Progress {
96+
pub enum Progress {
10297
/// We could not find any checkboxes or other deatils on the tracking issue.
10398
/// So all we have is "open" or "closed".
104-
Binary,
99+
Binary {
100+
is_closed: bool,
101+
},
105102

106103
/// We found checkboxes or issue listing.
107104
Tracked {
@@ -123,14 +120,40 @@ struct TrackingIssueUpdate {
123120
pub url: String,
124121
}
125122

123+
impl Progress {
124+
/// Returns the number of completed and total items. Returns (0, 0) in the case of an error.
125+
pub fn completed_total(&self) -> (u32, u32) {
126+
match *self {
127+
Progress::Binary { is_closed } => {
128+
if is_closed {
129+
(1, 1)
130+
} else {
131+
(0, 1)
132+
}
133+
}
134+
Progress::Tracked { completed, total } => (completed, total),
135+
Progress::Error { .. } => (0, 0),
136+
}
137+
}
138+
}
139+
126140
/// Identify how many sub-items have been completed.
127141
/// These can be encoded in two different ways:
128142
///
129143
/// * Option A, the most common, is to have checkboxes in the issue. We just count the number that are checked.
130144
/// * Option B is to include a metadata line called "Tracked issues" that lists a search query. We count the number of open vs closed issues in that query.
131145
///
132146
/// Returns a tuple (completed, total) with the number of completed items and the total number of items.
133-
fn checkboxes(issue: &ExistingGithubIssue) -> anyhow::Result<Progress> {
147+
pub fn checkboxes(issue: &ExistingGithubIssue) -> Progress {
148+
match try_checkboxes(&issue) {
149+
Ok(pair) => pair,
150+
Err(e) => Progress::Error {
151+
message: e.to_string(),
152+
},
153+
}
154+
}
155+
156+
fn try_checkboxes(issue: &ExistingGithubIssue) -> anyhow::Result<Progress> {
134157
let mut completed = 0;
135158
let mut total = 0;
136159

@@ -161,9 +184,9 @@ fn checkboxes(issue: &ExistingGithubIssue) -> anyhow::Result<Progress> {
161184
let repository = Repository::new(&c["org"], &c["repo"]);
162185
let issue_number = c["issue"].parse::<u64>()?;
163186
let issue = fetch_issue(&repository, issue_number)?;
164-
match checkboxes(&issue)? {
165-
Progress::Binary => {
166-
if issue.state == ExistingIssueState::Closed {
187+
match try_checkboxes(&issue)? {
188+
Progress::Binary { is_closed } => {
189+
if is_closed {
167190
completed += 1;
168191
}
169192
total += 1;
@@ -193,7 +216,9 @@ fn checkboxes(issue: &ExistingGithubIssue) -> anyhow::Result<Progress> {
193216
}
194217

195218
if total == 0 && completed == 0 {
196-
Ok(Progress::Binary)
219+
Ok(Progress::Binary {
220+
is_closed: issue.state == ExistingIssueState::Closed,
221+
})
197222
} else {
198223
Ok(Progress::Tracked { completed, total })
199224
}

mdbook-goals/src/llm.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
//! Code to invoke a LLM to summarize content and generate blog posts.
2+
//! Currently based on AWS bedrock.
3+
4+
use anyhow::Context;
5+
use aws_config::{
6+
environment::EnvironmentVariableCredentialsProvider,
7+
imds::credentials::ImdsCredentialsProvider,
8+
meta::{credentials::CredentialsProviderChain, region::RegionProviderChain},
9+
profile::{ProfileFileCredentialsProvider, ProfileFileRegionProvider},
10+
BehaviorVersion, Region,
11+
};
12+
use aws_sdk_bedrockruntime::types::{
13+
ContentBlock, ContentBlockDelta, ConversationRole, ConverseStreamOutput,
14+
InferenceConfiguration, Message,
15+
};
16+
use serde::{Deserialize, Serialize};
17+
18+
pub struct LargeLanguageModel {
19+
#[expect(dead_code)]
20+
aws_config: aws_config::SdkConfig,
21+
bedrock_runtime_client: aws_sdk_bedrockruntime::Client,
22+
#[expect(dead_code)]
23+
bedrock_client: aws_sdk_bedrock::Client,
24+
inference_parameters: InferenceConfiguration,
25+
model_id: ArgModel,
26+
}
27+
28+
#[derive(Clone, Serialize, Deserialize, Debug, Copy)]
29+
pub enum ArgModel {
30+
Llama270b,
31+
CohereCommand,
32+
ClaudeV2,
33+
ClaudeV21,
34+
ClaudeV3Sonnet,
35+
ClaudeV3Haiku,
36+
ClaudeV35Sonnet,
37+
Jurrasic2Ultra,
38+
TitanTextExpressV1,
39+
Mixtral8x7bInstruct,
40+
Mistral7bInstruct,
41+
MistralLarge,
42+
MistralLarge2,
43+
}
44+
45+
impl std::fmt::Display for ArgModel {
46+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47+
write!(f, "{}", self.model_id_str())
48+
}
49+
}
50+
51+
impl ArgModel {
52+
pub fn model_id_str(&self) -> &'static str {
53+
match self {
54+
ArgModel::ClaudeV2 => "anthropic.claude-v2",
55+
ArgModel::ClaudeV21 => "anthropic.claude-v2:1",
56+
ArgModel::ClaudeV3Haiku => "anthropic.claude-3-haiku-20240307-v1:0",
57+
ArgModel::ClaudeV3Sonnet => "anthropic.claude-3-sonnet-20240229-v1:0",
58+
ArgModel::ClaudeV35Sonnet => "anthropic.claude-3-5-sonnet-20240620-v1:0",
59+
ArgModel::Llama270b => "meta.llama2-70b-chat-v1",
60+
ArgModel::CohereCommand => "cohere.command-text-v14",
61+
ArgModel::Jurrasic2Ultra => "ai21.j2-ultra-v1",
62+
ArgModel::TitanTextExpressV1 => "amazon.titan-text-express-v1",
63+
ArgModel::Mixtral8x7bInstruct => "mistral.mixtral-8x7b-instruct-v0:1",
64+
ArgModel::Mistral7bInstruct => "mistral.mistral-7b-instruct-v0:2",
65+
ArgModel::MistralLarge => "mistral.mistral-large-2402-v1:0",
66+
ArgModel::MistralLarge2 => "mistral.mistral-large-2407-v1:0",
67+
}
68+
}
69+
}
70+
71+
impl LargeLanguageModel {
72+
pub async fn new() -> Self {
73+
let aws_config = Self::aws_config("us-east-1", "default").await;
74+
let bedrock_runtime_client = aws_sdk_bedrockruntime::Client::new(&aws_config);
75+
let bedrock_client = aws_sdk_bedrock::Client::new(&aws_config);
76+
let inference_parameters = InferenceConfiguration::builder().build();
77+
Self {
78+
aws_config,
79+
bedrock_runtime_client,
80+
bedrock_client,
81+
inference_parameters,
82+
model_id: ArgModel::ClaudeV3Sonnet,
83+
}
84+
}
85+
86+
pub async fn query(&self, prompt: &str, query: &str) -> anyhow::Result<String> {
87+
use std::fmt::Write;
88+
89+
let mut output = self
90+
.bedrock_runtime_client
91+
.converse_stream()
92+
.model_id(self.model_id.model_id_str())
93+
.messages(
94+
Message::builder()
95+
.role(ConversationRole::Assistant)
96+
.content(ContentBlock::Text(prompt.to_string()))
97+
.role(ConversationRole::User)
98+
.content(ContentBlock::Text(query.to_string()))
99+
.build()
100+
.with_context(|| "failed to build message")?,
101+
)
102+
.inference_config(self.inference_parameters.clone())
103+
.send()
104+
.await?;
105+
106+
let mut result = String::new();
107+
loop {
108+
let token = output.stream.recv().await?;
109+
match token {
110+
Some(ConverseStreamOutput::ContentBlockDelta(event)) => match event.delta() {
111+
Some(ContentBlockDelta::Text(text)) => write!(result, "{text}")?,
112+
Some(delta) => panic!("unexpected response from bedrock: {delta:?}"),
113+
None => (),
114+
},
115+
116+
Some(_) => { /* ignore other messages */ }
117+
118+
None => break,
119+
}
120+
}
121+
122+
Ok(result)
123+
}
124+
125+
async fn aws_config(fallback_region: &str, profile_name: &str) -> aws_config::SdkConfig {
126+
let region_provider = RegionProviderChain::first_try(
127+
ProfileFileRegionProvider::builder()
128+
.profile_name(profile_name)
129+
.build(),
130+
)
131+
.or_else(aws_config::environment::EnvironmentVariableRegionProvider::new())
132+
.or_else(aws_config::imds::region::ImdsRegionProvider::builder().build())
133+
.or_else(Region::new(fallback_region.to_string()));
134+
135+
let credentials_provider = CredentialsProviderChain::first_try(
136+
"Environment",
137+
EnvironmentVariableCredentialsProvider::new(),
138+
)
139+
.or_else(
140+
"Profile",
141+
ProfileFileCredentialsProvider::builder()
142+
.profile_name(profile_name)
143+
.build(),
144+
)
145+
.or_else("IMDS", ImdsCredentialsProvider::builder().build());
146+
147+
aws_config::defaults(BehaviorVersion::latest())
148+
.credentials_provider(credentials_provider)
149+
.region(region_provider)
150+
.load()
151+
.await
152+
}
153+
}

mdbook-goals/src/main.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use walkdir::WalkDir;
1111
mod gh;
1212
mod goal;
1313
mod json;
14+
mod llm;
1415
mod markwaydown;
1516
mod mdbook_preprocessor;
1617
mod re;
@@ -89,8 +90,11 @@ enum Command {
8990
/// Milestone for which we generate tracking issue data (e.g., `2024h2`).
9091
milestone: String,
9192

92-
/// Directory where we will write the output markdown files.
93-
output_directory: PathBuf,
93+
/// File in which to generate the update summary.
94+
/// The default is to generate a file named after the
95+
/// milestone, e.g., `2024h2.md`).
96+
#[structopt(long)]
97+
output_file: Option<PathBuf>,
9498

9599
/// Start date for comments.
96100
/// If not given, defaults to 1 week before the start of this month.
@@ -102,7 +106,8 @@ enum Command {
102106
},
103107
}
104108

105-
fn main() -> anyhow::Result<()> {
109+
#[tokio::main]
110+
async fn main() -> anyhow::Result<()> {
106111
let opt = Opt::from_args();
107112

108113
let Some(cmd) = &opt.cmd else {
@@ -150,17 +155,18 @@ fn main() -> anyhow::Result<()> {
150155
}
151156
Command::Updates {
152157
milestone,
153-
output_directory,
158+
output_file,
154159
start_date,
155160
end_date,
156161
} => {
157162
updates::updates(
158163
&opt.repository,
159164
milestone,
160-
output_directory,
165+
output_file.as_deref(),
161166
start_date,
162167
end_date,
163-
)?;
168+
)
169+
.await?;
164170
}
165171
}
166172

0 commit comments

Comments
 (0)