Skip to content

Commit 2de1f3c

Browse files
committed
Add smart follow-up detection for history context
Instead of always sending the previous exchange to the LLM, use a time + signal word heuristic to decide if the query is a follow-up: - Under 2 min: always include history - 2-10 min: only if query is short or contains follow-up words - Over 10 min: never include history Prevents unrelated questions from being misinterpreted as follow-ups to stale context. Backward compatible with old history files (missing timestamp treated as expired).
1 parent 280b36a commit 2de1f3c

4 files changed

Lines changed: 115 additions & 3 deletions

File tree

include/HistoryManager.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
#pragma once
22

3+
#include <ctime>
34
#include <optional>
45
#include <string>
56
#include <utility>
67

78
struct Exchange {
89
std::string userQuery;
910
std::string assistantReply;
11+
std::time_t timestamp{0};
1012
};
1113

1214
class HistoryManager {
@@ -19,6 +21,10 @@ class HistoryManager {
1921
/// Save the current exchange, creating the directory and enforcing 0600.
2022
void save(const std::string& userQuery, const std::string& assistantReply) const;
2123

24+
/// Decide whether the current query is a follow-up to a previous exchange.
25+
[[nodiscard]] static bool isFollowUp(const Exchange& previous, const std::string& currentQuery,
26+
std::time_t now = std::time(nullptr));
27+
2228
private:
2329
[[nodiscard]] std::string resolvePath() const;
2430

src/HistoryManager.cpp

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
#include "HistoryManager.h"
22

3+
#include <algorithm>
4+
#include <ranges>
5+
#include <cctype>
36
#include <cstdlib>
47
#include <filesystem>
58
#include <fstream>
69
#include <nlohmann/json.hpp>
10+
#include <sstream>
711
#include <stdexcept>
812
#include <sys/stat.h>
13+
#include <unordered_set>
914

1015
HistoryManager::HistoryManager(std::string path) : path_(std::move(path)) {}
1116

@@ -30,8 +35,10 @@ std::optional<Exchange> HistoryManager::loadPrevious() const {
3035

3136
try {
3237
auto json = nlohmann::json::parse(file);
38+
std::time_t ts = json.value("timestamp", static_cast<std::time_t>(0));
3339
return Exchange{.userQuery = json.at("user").get<std::string>(),
34-
.assistantReply = json.at("assistant").get<std::string>()};
40+
.assistantReply = json.at("assistant").get<std::string>(),
41+
.timestamp = ts};
3542
} catch (const nlohmann::json::exception&) {
3643
return std::nullopt;
3744
}
@@ -46,6 +53,7 @@ void HistoryManager::save(const std::string& userQuery, const std::string& assis
4653
nlohmann::json json;
4754
json["user"] = userQuery;
4855
json["assistant"] = assistantReply;
56+
json["timestamp"] = std::time(nullptr);
4957

5058
std::ofstream file(resolved, std::ios::trunc);
5159
if (!file.is_open()) {
@@ -59,3 +67,50 @@ void HistoryManager::save(const std::string& userQuery, const std::string& assis
5967
throw std::runtime_error("Cannot set permissions on history file: " + resolved);
6068
}
6169
}
70+
71+
namespace {
72+
73+
std::vector<std::string> splitAndLower(const std::string& text) {
74+
std::vector<std::string> words;
75+
std::istringstream stream(text);
76+
std::string word;
77+
while (stream >> word) {
78+
std::ranges::transform(word, word.begin(),
79+
[](unsigned char c) { return std::tolower(c); });
80+
words.push_back(std::move(word));
81+
}
82+
return words;
83+
}
84+
85+
} // namespace
86+
87+
bool HistoryManager::isFollowUp(const Exchange& previous, const std::string& currentQuery,
88+
std::time_t now) {
89+
const double elapsed = std::difftime(now, previous.timestamp);
90+
constexpr double kRapidWindow = 120.0; // 2 minutes
91+
constexpr double kMaxWindow = 600.0; // 10 minutes
92+
93+
if (elapsed > kMaxWindow) {
94+
return false;
95+
}
96+
if (elapsed <= kRapidWindow) {
97+
return true;
98+
}
99+
100+
// Between 2–10 minutes: check for follow-up signals
101+
auto words = splitAndLower(currentQuery);
102+
if (!words.empty()) {
103+
words.erase(words.begin()); // drop command name
104+
}
105+
106+
if (words.size() <= 3) {
107+
return true;
108+
}
109+
110+
static const std::unordered_set<std::string> signals = {
111+
"it", "this", "that", "them", "those", "these", "also", "again",
112+
"instead", "but", "more", "another", "same", "similar", "too", "else",
113+
"however", "alternatively", "previous", "last", "earlier", "before", "above",
114+
};
115+
return std::ranges::any_of(words, [&](const std::string& w) { return signals.contains(w); });
116+
}

src/main.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ int main(int argc, char* argv[]) {
5454
const HistoryManager history;
5555
std::vector<Message> messages;
5656

57-
auto previous = history.loadPrevious();
58-
if (previous) {
57+
const auto previous = history.loadPrevious();
58+
if (previous && HistoryManager::isFollowUp(*previous, query.str())) {
5959
messages.push_back({.role = "user", .content = previous->userQuery});
6060
messages.push_back({.role = "assistant", .content = previous->assistantReply});
6161
}

tests/HistoryManagerTest.cpp

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,57 @@ TEST_F(HistoryManagerTest, SaveCreatesParentDirectories) {
9999
EXPECT_EQ(result->userQuery, "query");
100100
}
101101

102+
// --- Follow-up detection tests ---
103+
104+
TEST(FollowUpTest, ExpiredHistoryReturnsFalse) {
105+
Exchange ex{.userQuery = "how to list files",
106+
.assistantReply = "Use ls",
107+
.timestamp = 1000};
108+
// 20 minutes later
109+
EXPECT_FALSE(HistoryManager::isFollowUp(ex, "how does photosynthesis work", 2200));
110+
}
111+
112+
TEST(FollowUpTest, RecentHistoryAlwaysReturnsTrue) {
113+
Exchange ex{.userQuery = "how to list files",
114+
.assistantReply = "Use ls",
115+
.timestamp = 1000};
116+
// 30 seconds later, unrelated query
117+
EXPECT_TRUE(HistoryManager::isFollowUp(ex, "how does photosynthesis work", 1030));
118+
}
119+
120+
TEST(FollowUpTest, MediumAgeWithSignalWordReturnsTrue) {
121+
Exchange ex{.userQuery = "how to list files",
122+
.assistantReply = "Use ls",
123+
.timestamp = 1000};
124+
// 5 minutes later, "but" is a signal word
125+
EXPECT_TRUE(HistoryManager::isFollowUp(ex, "how but only in the home directory", 1300));
126+
}
127+
128+
TEST(FollowUpTest, MediumAgeWithShortQueryReturnsTrue) {
129+
Exchange ex{.userQuery = "how to list files",
130+
.assistantReply = "Use ls",
131+
.timestamp = 1000};
132+
// 5 minutes later, short query (2 content words after command name)
133+
EXPECT_TRUE(HistoryManager::isFollowUp(ex, "how in rust", 1300));
134+
}
135+
136+
TEST(FollowUpTest, MediumAgeWithLongUnrelatedQueryReturnsFalse) {
137+
Exchange ex{.userQuery = "how to list files",
138+
.assistantReply = "Use ls",
139+
.timestamp = 1000};
140+
// 5 minutes later, long query with no signal words
141+
EXPECT_FALSE(HistoryManager::isFollowUp(ex, "how does photosynthesis work in plants", 1300));
142+
}
143+
144+
TEST(FollowUpTest, ZeroTimestampReturnsFalse) {
145+
Exchange ex{.userQuery = "how to list files",
146+
.assistantReply = "Use ls",
147+
.timestamp = 0};
148+
EXPECT_FALSE(HistoryManager::isFollowUp(ex, "how in rust", std::time(nullptr)));
149+
}
150+
151+
// --- Existing tests ---
152+
102153
TEST_F(HistoryManagerTest, SaveProducesValidJson) {
103154
auto path = historyPath();
104155
HistoryManager mgr(path);

0 commit comments

Comments
 (0)